Compare commits

...

106 Commits

Author SHA1 Message Date
Peter Steinberger
c11063485a fix(agents): centralize idle-wait flush and clear timeout handle 2026-02-13 20:34:57 +01:00
Renue Development
bd2ea02541 fix(agents): add waitForIdle to all flushPendingToolResults call sites
The original fix only covered the main run finally block, but there are
two additional call sites that can trigger flushPendingToolResults while
tools are still executing:

1. The catch block in attempt.ts (session setup error handler)
2. The finally block in compact.ts (compaction teardown)

Both now await agent.waitForIdle() with a 30s timeout before flushing,
matching the pattern already applied to the main finally block.

Production testing on VPS with debug logging confirmed these additional
paths can fire during sub-agent runs, producing spurious synthetic
'missing tool result' errors.
2026-02-11 13:37:30 +11:00
rodbland2021
d602bc16a1 Merge branch 'main' into fix/tool-result-flush-race 2026-02-11 11:40:41 +11:00
Omair Afzal
88428260ce fix(web_search): remove unsupported include param from Grok API calls (#12910) (#12945)
xAI's /v1/responses endpoint does not support the 'include' parameter,
returning 400 'Argument not supported: include'. Inline citations are
returned automatically when available — no explicit request needed.

Closes #12910

Co-authored-by: Luna AI <luna@coredirection.ai>
2026-02-10 18:27:35 -06:00
Bill Chirico
ca629296c6 feat(hooks): add agentId support to webhook mappings (#13672)
* feat(hooks): add agentId support to webhook mappings

Allow webhook mappings to route hook runs to a specific agent via
the new `agentId` field. This enables lightweight agents with minimal
bootstrap files to handle webhooks, reducing token cost per hook run.

The agentId is threaded through:
- HookMappingConfig (config type + zod schema)
- HookMappingResolved + HookAction (mapping types)
- normalizeHookMapping + buildActionFromMapping (mapping logic)
- mergeAction (transform override support)
- HookAgentPayload + normalizeAgentPayload (direct /hooks/agent endpoint)
- dispatchAgentHook → CronJob.agentId (server dispatch)

The existing runCronIsolatedAgentTurn already supports agentId on
CronJob — this change simply wires it through from webhook mappings.

Usage in config:
  hooks.mappings[].agentId = "my-agent"

Usage via POST /hooks/agent:
  { "message": "...", "agentId": "my-agent" }

Includes tests for mapping passthrough and payload normalization.
Includes doc updates for webhook.md.

* fix(hooks): enforce webhook agent routing policy + docs/changelog updates (#13672) (thanks @BillChirico)

* fix(hooks): harden explicit agent allowlist semantics (#13672) (thanks @BillChirico)

---------

Co-authored-by: Pip <pip@openclaw.ai>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-10 19:23:58 -05:00
Renue Development
cd27b582c5 test: add tests for tool result flush race condition
Validates that:
- Real tool results are not replaced by synthetic errors when they arrive in time
- Flush correctly inserts synthetic errors for genuinely orphaned tool calls
- Flush is a no-op after real tool results have already been received

Refs #8643, #13748
2026-02-11 11:16:13 +11:00
Marcus Castro
45488e4ec9 fix: remap session JSONL chunk line numbers to original source positions (#12102)
* fix: remap session JSONL chunk line numbers to original source positions

buildSessionEntry() flattens JSONL messages into plain text before
chunkMarkdown() assigns line numbers. The stored startLine/endLine
values therefore reference positions in the flattened text, not the
original JSONL file.

- Add lineMap to SessionFileEntry tracking which JSONL line each
  extracted message came from
- Add remapChunkLines() to translate chunk positions back to original
  JSONL lines after chunking
- Guard remap with source === "sessions" to prevent misapplication
- Include lineMap in content hash so existing sessions get re-indexed

Fixes #12044

* memory: dedupe session JSONL parsing

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-10 18:09:24 -06:00
Renue Development
374eea8823 fix(agents): wait for agent idle before flushing pending tool results
When pi-agent-core's auto-retry mechanism handles overloaded/rate-limit
errors, it resolves waitForRetry() on assistant message receipt — before
tool execution completes in the retried agent loop. This causes the
attempt's finally block to call flushPendingToolResults() while tools
are still executing, inserting synthetic 'missing tool result' errors
and causing silent agent failures.

The fix adds a waitForIdle() call before the flush to ensure the agent's
retry loop (including tool execution) has fully completed.

Evidence from real session: tool call and synthetic error were only 53ms
apart — the tool never had a chance to execute before being flushed.

Root cause is in pi-agent-core's _resolveRetry() firing on message_end
instead of agent_end, but this workaround in OpenClaw prevents the
symptom without requiring an upstream fix.

Fixes #8643
Fixes #13351
Refs #6682, #12595
2026-02-11 10:56:49 +11:00
Onur
424d2dddf5 fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* fix(browser): prevent permanent timeout after stuck evaluate

Thread AbortSignal from client-fetch through dispatcher to Playwright
operations. When a timeout fires, force-disconnect the Playwright CDP
connection to unblock the serialized command queue, allowing the next
call to reconnect transparently.

Key changes:
- client-fetch.ts: proper AbortController with signal propagation
- pw-session.ts: new forceDisconnectPlaywrightForTarget()
- pw-tools-core.interactions.ts: accept signal, align inner timeout
  to outer-500ms, inject in-browser Promise.race for async evaluates
- routes/dispatcher.ts + types.ts: propagate signal through dispatch
- server.ts + bridge-server.ts: Express middleware creates AbortSignal
  from request lifecycle
- client-actions-core.ts: add timeoutMs to evaluate type

Fixes #10994

* fix(browser): v2 - force-disconnect via Connection.close() instead of browser.close()

When page.evaluate() is stuck on a hung CDP transport, browser.close() also
hangs because it tries to send a close command through the same stuck pipe.

v2 fix: forceDisconnectPlaywrightForTarget now directly calls Playwright's
internal Connection.close() which locally rejects all pending callbacks and
emits 'disconnected' without touching the network. This instantly unblocks
all stuck Playwright operations.

closePlaywrightBrowserConnection (clean shutdown) now also has a 3s timeout
fallback that drops to forceDropConnection if browser.close() hangs.

Fixes permanent browser timeout after stuck evaluate.

* fix(browser): v3 - fire-and-forget browser.close() instead of Connection.close()

v2's forceDropConnection called browser._connection.close() which corrupts
the entire Playwright instance because Connection is shared across all
objects (BrowserType, Browser, Page, etc.). This prevented reconnection
with cascading 'connectOverCDP: Force-disconnected' errors.

v3 fix: forceDisconnectPlaywrightForTarget now:
1. Nulls cached connection immediately
2. Fire-and-forgets browser.close() (doesn't await — it may hang)
3. Next connectBrowser() creates a fresh connectOverCDP WebSocket

Each connectOverCDP creates an independent WebSocket to the CDP endpoint,
so the new connection is unaffected by the old one's pending close.
The old browser.close() eventually resolves when the in-browser evaluate
timeout fires, or the old connection gets GC'd.

* fix(browser): v4 - clear connecting state and remove stale disconnect listeners

The reconnect was failing because:
1. forceDisconnectPlaywrightForTarget nulled cached but not connecting,
   so subsequent calls could await a stale promise
2. The old browser's 'disconnected' event handler raced with new
   connections, nulling the fresh cached reference

Fix: null both cached and connecting, and removeAllListeners on the
old browser before fire-and-forget close.

* fix(browser): v5 - use raw CDP Runtime.terminateExecution to kill stuck evaluate

When forceDisconnectPlaywrightForTarget fires, open a raw WebSocket
to the stuck page's CDP endpoint and send Runtime.terminateExecution.
This kills running JS without navigating away or crashing the page.
Also clear connecting state and remove stale disconnect listeners.

* fix(browser): abort cancels stuck evaluate

* Browser: always cleanup evaluate abort listener

* Chore: remove Playwright debug scripts

* Docs: add CDP evaluate refactor plan

* Browser: refactor Playwright force-disconnect

* Browser: abort stops evaluate promptly

* Node host: extract withTimeout helper

* Browser: remove disconnected listener safely

* Changelog: note act:evaluate hang fix

---------

Co-authored-by: Bob <bob@dutifulbob.com>
2026-02-11 07:54:48 +08:00
Riccardo Giorato
be6de9bb75 Update Together default model to together/moonshotai/Kimi-K2.5 (#13324) 2026-02-11 08:39:15 +09:00
Vignesh
fa906b26ad feat: IRC — add first-class channel support
Adds IRC as a first-class channel with core config surfaces (schema/hints/dock), plugin auto-enable detection, routing/policy alignment, and docs/tests.

Co-authored-by: Vignesh <vigneshnatarajan92@gmail.com>
2026-02-10 17:33:57 -06:00
Sebastian
90f58333e9 test(docker): make bash 3.2 compatibility check portable 2026-02-10 18:04:48 -05:00
Daniel Olshansky
31f616d45b feat: ClawDock - shell docker helpers for OpenClaw development (#12817)
Discussion: https://github.com/openclaw/openclaw/discussions/13528

## Checklist

- [x] **Mark as AI-assisted in the PR title or description** - Implemented by 🤖, reviewed by 👨‍💻 
- [x] **Note the degree of testing** - fully tested and I use it myself
- [x] **Include prompts or session logs if possible (super helpful!)** - I can try doing a "resume" on a few sessions, but don't think it'll provide value. Lmk if this is a blocker.
- [x] **Confirm you understand what the code does** - It's simple :)

## Summary of changes

- **ClawDock** - Shell helpers replace verbose `docker-compose` commands with simple `clawdock-*` shortcuts
- **Zero-config setup** - First run auto-detects the OpenClaw project directory from common paths and saves the config for future use
- **No extra dependencies** - Just bash
- **Built-in auth & device pairing helpers** - `clawdock-fix-token`, `clawdock-dashboard`, etc to handle gateay setup, streamline web UI, etc...
- **Updated Docker docs** - Installation docs now include the optional ClawDock helper setup for users who want simplified container management

## Example Usage

```bash
$ clawdock-help

🦞 ClawDock - Docker Helpers for OpenClaw

⚡ Basic Operations
  clawdock-start       Start the gateway
  clawdock-stop        Stop the gateway
  clawdock-restart     Restart the gateway
  clawdock-status      Check container status
  clawdock-logs        View live logs (follows)

🐚 Container Access
  clawdock-shell       Shell into container (openclaw alias ready)
  clawdock-cli         Run CLI commands (e.g., clawdock-cli status)
  clawdock-exec <cmd>  Execute command in gateway container

🌐 Web UI & Devices
  clawdock-dashboard   Open web UI in browser (auto-guides you)
  clawdock-devices     List device pairings (auto-guides you)
  clawdock-approve <id> Approve device pairing (with examples)

⚙️  Setup & Configuration
  clawdock-fix-token   Configure gateway token (run once)

🔧 Maintenance
  clawdock-rebuild     Rebuild Docker image
  clawdock-clean       ⚠️  Remove containers & volumes (nuclear)

🛠️  Utilities
  clawdock-health      Run health check
  clawdock-token       Show gateway auth token
  clawdock-cd          Jump to openclaw project directory
  clawdock-config      Open config directory (~/.openclaw)
  clawdock-workspace   Open workspace directory

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚀 First Time Setup
  1. clawdock-start          # Start the gateway
  2. clawdock-fix-token      # Configure token
  3. clawdock-dashboard      # Open web UI
  4. clawdock-devices        # If pairing needed
  5. clawdock-approve <id>   # Approve pairing

💬 WhatsApp Setup
  clawdock-shell
    > openclaw channels login --channel whatsapp
    > openclaw status

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

💡 All commands guide you through next steps!
📚 Docs: https://docs.openclaw.ai
```\n\nCo-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-10 16:04:41 -05:00
Gustavo Madeira Santana
2dcaaa4b61 chore: add format:fix to AGENTS 2026-02-10 15:46:03 -05:00
Gustavo Madeira Santana
a6187b568c chore: add missing CHANGELOG entry 2026-02-10 15:12:37 -05:00
Gustavo Madeira Santana
c4d3800c29 fix: resolve message tool lint error (#13453) (thanks @liebertar) 2026-02-10 15:09:58 -05:00
Cklee
22458f57f2 fix(agents): strip [Historical context: ...] and tool call text from streaming path (#13453)
- Add [Historical context: ...] marker pattern to stripDowngradedToolCallText
- Apply stripDowngradedToolCallText in emitBlockChunk streaming path
- Previously only stripBlockTags ran during streaming, leaking [Tool Call: ...] markers to users
- Add 7 test cases for the new pattern stripping
2026-02-10 15:04:52 -05:00
meaadore1221-afk
67d25c6533 fix: strip reasoning tags from messaging tool text to prevent <think> leakage (#11053)
Co-authored-by: MEA <mea@MEAdeMac-mini.local>
2026-02-10 14:48:17 -05:00
williamtwomey
5fab11198d Fix matrix media attachments (#12967) thanks @williamtwomey
Co-authored-by: Gustavo Madeira Santana @gumadeiras
2026-02-10 13:18:47 -05:00
Shadow
96c46ed612 Docs: restore maintainers in contributing 2026-02-10 10:33:32 -06:00
Shadow
71fd054711 Revert "fix(credits): deduplicate contributors by GitHub username and display name"
This reverts commit d2f5d45f08.
2026-02-10 10:25:51 -06:00
Shadow
614befd15d Revert "credits: categorize direct changes, exclude bots, fix MDX (#13322)"
This reverts commit 8666d9f837.
2026-02-10 10:25:48 -06:00
Shadow
cfd1fa4bd2 Revert "CI: extend stale timelines to be contributor-friendly (#13209)"
This reverts commit 656a467518.
2026-02-10 10:24:28 -06:00
Sebastian
8933010e84 docs(env): clarify .env precedence and config token note 2026-02-10 10:18:36 -05:00
Omair Afzal
6ac56baf8e docs: clarify which workspace files are injected into context window (#12937)
* docs: clarify which workspace files are injected into context window (#12909)

The system prompt docs listed bootstrap files but omitted MEMORY.md,
which IS injected when present. This led users to assume memory files
are on-demand only and not consuming context tokens.

Changes:
- Add MEMORY.md to the bootstrap file list
- Note that all listed files consume tokens on every turn
- Clarify that memory/*.md daily files are NOT injected (on-demand only)
- Document sub-agent bootstrap filtering (AGENTS.md + TOOLS.md only)

Closes #12909

* docs: mention memory.md alternate filename in bootstrap list

Address review feedback: the runtime also injects lowercase memory.md
(DEFAULT_MEMORY_ALT_FILENAME) when present.

* docs: align memory bootstrap docs (#12937) (thanks @omair445)

---------

Co-authored-by: Luna AI <luna@coredirection.ai>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-10 10:06:23 -05:00
Mateusz Michalik
6731c6a1cd fix(docker): support Bash 3.2 in docker-setup.sh (#9441)
* fix(docker): use Bash 3.2-compatible upsert_env in docker-setup.sh

* refactor(docker): simplify argument handling in write_extra_compose function

* fix(docker): add bash 3.2 regression coverage (#9441) (thanks @mateusz-michalik)

---------

Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-10 09:55:43 -05:00
Gustavo Madeira Santana
2914cb1d48 Onboard: rename Custom API Endpoint to Custom Provider 2026-02-10 07:36:04 -05:00
Blossom
c0befdee0b feat(onboard): add custom/local API configuration flow (#11106)
* feat(onboard): add custom/local API configuration flow

* ci: retry macos check

* fix: expand custom API onboarding (#11106) (thanks @MackDing)

* fix: refine custom endpoint detection (#11106) (thanks @MackDing)

* fix: streamline custom endpoint onboarding (#11106) (thanks @MackDing)

* fix: skip model picker for custom endpoint (#11106) (thanks @MackDing)

* fix: avoid allowlist picker for custom endpoint (#11106) (thanks @MackDing)

* Onboard: reuse shared fetch timeout helper (#11106) (thanks @MackDing)

* Onboard: clarify default base URL name (#11106) (thanks @MackDing)

---------

Co-authored-by: OpenClaw Contributor <contributor@openclaw.ai>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-10 07:31:02 -05:00
max
8666d9f837 credits: categorize direct changes, exclude bots, fix MDX (#13322) 2026-02-10 02:27:48 -08:00
max
d2f5d45f08 fix(credits): deduplicate contributors by GitHub username and display name
* Scripts: add sync-credits.py to populate maintainers/contributors from git/GitHub

* fix(credits): deduplicate contributors by GitHub username and display name
2026-02-10 02:04:29 -08:00
vignesh07
53fd26a960 maintainers: mention QMD 2026-02-10 01:14:00 -08:00
vignesh07
d8b9aff2f5 update maintainers 2026-02-10 01:05:20 -08:00
quotentiroler
bf308cf6a8 CI: expand Docker Release paths-ignore to skip on any markdown 2026-02-10 00:39:26 -08:00
quotentiroler
57e60f5c05 Docs: consolidate maintainer PR workflow into PR_WORKFLOW.md 2026-02-10 00:33:24 -08:00
Vignesh Natarajan
8d80212f99 docs: credit memorySearch changelog 2026-02-10 00:21:27 -08:00
Vignesh Natarajan
a76dea0d23 Config: clarify memorySearch migration precedence 2026-02-10 00:21:27 -08:00
Vignesh Natarajan
8688730161 Config: migrate legacy top-level memorySearch 2026-02-10 00:21:27 -08:00
Vignesh Natarajan
efc79f69a2 Gateway: eager-init QMD backend on startup 2026-02-09 23:58:34 -08:00
Vignesh
ef4a0e92b7 fix(memory/qmd): scope query to managed collections (#11645) 2026-02-09 23:35:27 -08:00
quotentiroler
40919b1fc8 fix(test): add StringSelectMenu to @buape/carbon mock 2026-02-09 23:11:53 -08:00
Peter Steinberger
53273b490b fix(auto-reply): prevent sender spoofing in group prompts 2026-02-10 00:44:38 -06:00
Shadow
8ff1618bfc Discord: add exec approval cleanup option (#13205) 2026-02-10 00:39:42 -06:00
max
656a467518 CI: extend stale timelines to be contributor-friendly (#13209)
Extends stale automation timelines:

- Issues: 30 days stale → 14 days close (44 total, was 12)
- PRs: 14 days stale → 7 days close (21 total, was 8)

PR #13209
2026-02-09 22:34:36 -08:00
Shadow
4537ebc43a fix: enforce Discord agent component DM auth (#11254) (thanks @thedudeabidesai) 2026-02-10 00:26:59 -06:00
max
f17c978f5c refactor(security,config): split oversized files (#13182)
refactor(security,config): split oversized files using dot-naming convention

- audit-extra.ts (1,199 LOC) -> barrel (31) + sync (559) + async (668)
- schema.ts (1,114 LOC) -> schema (353) + field-metadata (729)
- Add tmp-refactoring-strategy.md documenting Wave 1-4 plan

PR #13182
2026-02-09 22:22:29 -08:00
Shadow
47f6bb4146 Commands: add commands.allowFrom config 2026-02-09 23:58:52 -06:00
Shadow
e7f0769c82 CI: configure stale automation 2026-02-09 23:37:12 -06:00
zerone0x
1d46ca3a95 fix(signal): enforce mention gating for group messages (#13124)
* fix(signal): enforce mention gating for group messages

Signal group messages bypassed mention gating, causing the bot to reply
even when requireMention was enabled and the message did not mention
the bot. This aligns Signal with Slack, Discord, Telegram, and iMessage
which all enforce mention gating correctly.

Fixes #13106

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(signal): keep pending history context for mention-gated skips (#13124) (thanks @zerone0x)

---------

Co-authored-by: Yansu <no-reply@yansu.ai>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-09 23:19:07 -06:00
Marcus Castro
137b7d9aab fix(ui): prioritize displayName over label in webchat session picker (#13108)
* fix(ui): prioritize displayName over label in webchat session picker

The session picker dropdown in the webchat UI was showing raw session
keys instead of human-readable display names. resolveSessionDisplayName()
checked label before displayName and formatted displayName-based entries
as key (displayName) instead of displayName (key).

Swap the priority so displayName is checked first, and use a consistent
humanName (key) format for both displayName and label fallbacks.

Fixes #6645

* test: use deterministic updatedAt in session display name tests
2026-02-10 00:02:54 -05:00
Shadow
f38dfe4544 Chore: add testflight auto-response 2026-02-09 22:52:46 -06:00
Tak Hoffman
72f89b1f53 Docker: include A2UI sources for bundle (#13114)
* Docker: include A2UI sources for bundle

* Build: fail bundling when sources missing and no prebuilt A2UI bundle
2026-02-09 22:44:59 -06:00
Gustavo Madeira Santana
e19a23520c fix: unify session maintenance and cron run pruning (#13083)
* fix: prune stale session entries, cap entry count, and rotate sessions.json

The sessions.json file grows unbounded over time. Every heartbeat tick (default: 30m)
triggers multiple full rewrites, and session keys from groups, threads, and DMs
accumulate indefinitely with large embedded objects (skillsSnapshot,
systemPromptReport). At >50MB the synchronous JSON parse blocks the event loop,
causing Telegram webhook timeouts and effectively taking the bot down.

Three mitigations, all running inside saveSessionStoreUnlocked() on every write:

1. Prune stale entries: remove entries with updatedAt older than 30 days
   (configurable via session.maintenance.pruneDays in openclaw.json)

2. Cap entry count: keep only the 500 most recently updated entries
   (configurable via session.maintenance.maxEntries). Entries without updatedAt
   are evicted first.

3. File rotation: if the existing sessions.json exceeds 10MB before a write,
   rename it to sessions.json.bak.{timestamp} and keep only the 3 most recent
   backups (configurable via session.maintenance.rotateBytes).

All three thresholds are configurable under session.maintenance in openclaw.json
with Zod validation. No env vars.

Existing tests updated to use Date.now() instead of epoch-relative timestamps
(1, 2, 3) that would be incorrectly pruned as stale.

27 new tests covering pruning, capping, rotation, and integration scenarios.

* feat: auto-prune expired cron run sessions (#12289)

Add TTL-based reaper for isolated cron run sessions that accumulate
indefinitely in sessions.json.

New config option:
  cron.sessionRetention: string | false  (default: '24h')

The reaper runs piggy-backed on the cron timer tick, self-throttled
to sweep at most every 5 minutes. It removes session entries matching
the pattern cron:<jobId>:run:<uuid> whose updatedAt + retention < now.

Design follows the Kubernetes ttlSecondsAfterFinished pattern:
- Sessions are persisted normally (observability/debugging)
- A periodic reaper prunes expired entries
- Configurable retention with sensible default
- Set to false to disable pruning entirely

Files changed:
- src/config/types.cron.ts: Add sessionRetention to CronConfig
- src/config/zod-schema.ts: Add Zod validation for sessionRetention
- src/cron/session-reaper.ts: New reaper module (sweepCronRunSessions)
- src/cron/session-reaper.test.ts: 12 tests covering all paths
- src/cron/service/state.ts: Add cronConfig/sessionStorePath to deps
- src/cron/service/timer.ts: Wire reaper into onTimer tick
- src/gateway/server-cron.ts: Pass config and session store path to deps

Closes #12289

* fix: sweep cron session stores per agent

* docs: add changelog for session maintenance (#13083) (thanks @skyfallsin, @Glucksberg)

* fix: add warn-only session maintenance mode

* fix: warn-only maintenance defaults to active session

* fix: deliver maintenance warnings to active session

* docs: add session maintenance examples

* fix: accept duration and size maintenance thresholds

* refactor: share cron run session key check

* fix: format issues and replace defaultRuntime.warn with console.warn

---------

Co-authored-by: Pradeep Elankumaran <pradeepe@gmail.com>
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
Co-authored-by: max <40643627+quotentiroler@users.noreply.github.com>
Co-authored-by: quotentiroler <max.nussbaumer@maxhealth.tech>
2026-02-09 20:42:35 -08:00
Jamieson O'Reilly
0657d7c772 docs: expand vulnerability reporting guidelines in SECURITY.md 2026-02-10 15:39:04 +11:00
Jamieson O'Reilly
b39669d1b4 docs: add vulnerability reporting guidelines to CONTRIBUTING.md 2026-02-10 15:39:04 +11:00
quotentiroler
a26670a2fb refactor: consolidate fetchWithTimeout into shared utility 2026-02-09 20:34:56 -08:00
Jake
757522fb48 fix(memory): default batch embeddings to off
Disables async batch embeddings by default for memory indexing; batch remains opt-in via agents.defaults.memorySearch.remote.batch.enabled.

(#13069) Thanks @mcinteerj.

Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
2026-02-09 22:31:58 -06:00
quotentiroler
5c62e4d51b Improve code analyzer for independent packages, CI: only run release-check on push to main 2026-02-09 19:57:13 -08:00
Evan Reid
0c7bc303c9 fix(tools): correct Grok response parsing for xAI Responses API (#13049)
* fix(tools): correct Grok response parsing for xAI Responses API

The xAI Responses API returns content in output[0].content[0].text,
not in output_text field. Updated GrokSearchResponse type and
runGrokSearch to extract content from the correct path.

Fixes the 'No response' issue when using Grok web search.

* fix(tools): harden Grok web_search parsing (#13049) (thanks @ereid7)

---------

Co-authored-by: erai <erai@erais-Mac-mini.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-09 21:51:24 -06:00
quotentiroler
8fad4c2844 chore(deps): update dependencies, remove hono pinning 2026-02-09 19:35:37 -08:00
quotentiroler
cc87c0ed7c Update contributing, deduplicate more functions 2026-02-09 19:21:33 -08:00
quotentiroler
453eaed4dc improve pre-commit hook 2026-02-09 18:59:42 -08:00
quotentiroler
53910f3643 Deduplicate more 2026-02-09 18:56:58 -08:00
max
c4d9b6eadb fix: docs broken links and improve link checker (#13056)
* docs: fix broken links checker and add CI docs

- Replace buggy mint broken-links with existing docs:check-links script
- Fix zh-CN/vps.md broken links (/railway  /install/railway)
- Add docs/ci.md explaining CI pipeline
- Add Experiments group to docs.json navigation

* improve docs checker
2026-02-09 18:45:06 -08:00
Rami Abdelrazzaq
c2b2d535fb fix: suggest /clear in context overflow error message (#12973)
* fix: suggest /reset in context overflow error message

When the context window overflows, the error message now suggests
using /reset to clear session history, giving users an actionable
recovery path instead of a dead-end error.

Closes #12940

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: suggest /reset in context overflow error message (#12973) (thanks @RamiNoodle733)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Rami Abdelrazzaq <RamiNoodle733@users.noreply.github.com>
2026-02-09 20:44:37 -06:00
Liu Yuan
33ee8bbf1d feat: add zai/glm-4.6v image understanding support (#10267)
Fixes #10265. Thanks @liuy.
2026-02-09 18:38:09 -08:00
Yida-Dev
d3c71875e4 fix: cap Discord gateway reconnect at 50 attempts to prevent infinite loop (#12230)
* fix: cap Discord gateway reconnect attempts to prevent infinite loop

The Discord GatewayPlugin was configured with maxAttempts: Infinity,
which causes an unbounded reconnection loop when the Discord gateway
enters a persistent failure state (e.g. code 1005 with stalled HELLO).

In production, this manifested as 2,483+ reconnection attempts in a
single log file, starving the Node.js event loop and preventing cron,
heartbeat, and other subsystems from functioning.

Cap maxAttempts at 50, which provides ~25 minutes of retry time
(with 30s HELLO timeout between attempts) before cleanly exiting
via the existing "Max reconnect attempts" error handler.

Closes #11836

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Changelog: note Discord gateway reconnect cap (#12230) (thanks @Yida-Dev)

---------

Co-authored-by: Yida-Dev <reyifeijun@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shadow <shadow@clawd.bot>
2026-02-09 20:36:43 -06:00
max
67d3bab890 docs: fix broken links checker and add CI docs (#13041)
- Fix zh-CN/vps.md broken links (/railway  /install/railway)
- Add docs/ci.md explaining CI pipeline
- Add Experiments group to docs.json navigation
2026-02-09 18:30:05 -08:00
magendary
ead3bb645f discord: auto-create thread when sending to Forum/Media channels (#12380)
* discord: auto-create thread when sending to Forum/Media channels

* Discord: harden forum thread sends (#12380) (thanks @magendary)

* fix: clean up discord send exports (#12380) (thanks @magendary)

---------

Co-authored-by: Shadow <shadow@clawd.bot>
2026-02-09 20:26:42 -06:00
quotentiroler
6d26ba3bb6 only check is check-docs when only docs changed 2026-02-09 18:05:13 -08:00
quotentiroler
59a4aaf376 Merge branch 'main' of https://github.com/openclaw/openclaw 2026-02-09 17:57:28 -08:00
quotentiroler
039aaf176e CI: cleanup and fix broken job references
- Fix code-size -> code-analysis job name (5 jobs had wrong dependency)
- Remove useless install-check job (was no-op)
- Add explicit docs_only guard to release-check
- Remove dead submodule checkout steps (no submodules in repo)
- Rename detect-docs-only -> detect-docs-changes, add docs_changed output
- Reorder check script: format first for faster fail
- Fix billing error test (PR #12946 removed fallback detection but not test)
2026-02-09 17:52:51 -08:00
Tak Hoffman
54315aeacf Agents: scope sanitizeUserFacingText rewrites to errorContext
Squash-merge #12988.

Refs: #12889 #12309 #3594 #7483 #10094 #10368 #11317 #11359 #11649 #12022 #12432 #12676 #12711
2026-02-09 19:52:24 -06:00
quotentiroler
64cf50dfc3 chore: rename format scripts for conventional naming
- format = fix (write)

- format:check = check only

- Update CI to use format:check
2026-02-09 17:11:16 -08:00
Shadow
8e607d927c Docs: require labeler + label updates for channels/extensions 2026-02-09 17:08:18 -08:00
max
8d75a496bf refactor: centralize isPlainObject, isRecord, isErrno, isLoopbackHost utilities (#12926) 2026-02-09 17:02:55 -08:00
Shadow
70f9edeec7 CI: check maintainer team membership for labels 2026-02-09 18:59:41 -06:00
Jabez Borja
8c73dbe705 fix(telegram): prevent false-positive billing error detection in conversation text (#12946) thanks @jabezborja 2026-02-09 19:49:31 -05:00
cpojer
49fb8f74e4 chore: Fix types after ChatType changes. 2026-02-10 09:20:39 +09:00
Yifeng Wang
5c2cb6c591 feat(feishu): sync community contributions from clawdbot-feishu (#12662)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:19:44 +09:00
peetzweg/
49c60e9065 feat(matrix): add thread session isolation (#8241)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 09:16:40 +09:00
George Pickett
a97db0c372 docs: add changelog entry for #9564 (#12963) 2026-02-09 16:11:52 -08:00
George Pickett
afec0f11f8 test: lock /think off persistence (#9564) 2026-02-09 16:08:15 -08:00
Liu Yuan
97b3ee7ec0 Fix: Honor /think off for reasoning-capable models
Problem:
When users execute `/think off`, they still receive `reasoning_content`
from models configured with `reasoning: true` (e.g., GLM-4.7, GLM-4.6,
Kimi K2.5, MiniMax-M2.1).

Expected: `/think off` should completely disable reasoning content.
Actual: Reasoning content is still returned.

Root Cause:
The directive handlers delete `sessionEntry.thinkingLevel` when user
executes `/think off`. This causes the thinking level to become undefined,
and the system falls back to `resolveThinkingDefault()`, which checks the
model catalog and returns "low" for reasoning-capable models, ignoring the
user's explicit intent.

Why We Must Persist "off" (Design Rationale):

1. **Model-dependent defaults**: Unlike other directives where "off" means
   use a global default, `thinkingLevel` has model-dependent defaults:
   - Reasoning-capable models (GLM-4.7, etc.) → default "low"
   - Other models → default "off"

2. **Existing pattern**: The codebase already follows this pattern for
   `elevatedLevel`, which persists "off" explicitly to override defaults
   that may be "on". The comment explains:
   "Persist 'off' explicitly so `/elevated off` actually overrides defaults."

3. **User intent**: When a user explicitly executes `/think off`, they want
   to disable thinking regardless of the model's capabilities. Deleting the
   field breaks this intent by falling back to the model's default.

Solution:
Persist "off" value instead of deleting the field in all internal directive handlers:
- `src/auto-reply/reply/directive-handling.impl.ts`: Directive-only messages
- `src/auto-reply/reply/directive-handling.persist.ts`: Inline directives
- `src/commands/agent.ts`: CLI command-line flags

Gateway API Backward Compatibility:
The original implementation incorrectly mapped `null` to "off" in
`sessions-patch.ts` for consistency with internal handlers. This was a
breaking change because:
- Previously, `null` cleared the override (deleted the field)
- API clients lost the ability to "clear to default" via `null`
- This contradicts standard JSON semantics where `null` means "no value"

Restored original null semantics in `src/gateway/sessions-patch.ts`:
- `null` → delete field, fall back to model default (clear override)
- `"off"` → persist explicit override
- Other values → normalize and persist

This ensures backward compatibility for API clients while fixing the `/think off`
issue in internal handlers.

Signed-off-by: Liu Yuan <namei.unix@gmail.com>
2026-02-09 16:08:15 -08:00
cpojer
fa21050af0 chore: Update deps. 2026-02-10 08:52:07 +09:00
Riccardo Giorato
661279cbfa feat: adding support for Together ai provider (#10304) 2026-02-10 08:49:34 +09:00
quotentiroler
ffeed212dc ci(docker): use registry cache for persistent layer storage 2026-02-09 15:05:37 -08:00
Tak Hoffman
4df252d895 Gateway: add CLAUDE.md symlink for AGENTS.md 2026-02-09 17:02:55 -06:00
Tak Hoffman
2f9014c6ff AGENTS: require CLAUDE.md symlink alongside new AGENTS.md 2026-02-09 17:02:55 -06:00
Rodrigo Uroz
ae99e656af (fix): .env vars not available during runtime config reloads (healthchecks fail with MissingEnvVarError) (#12748)
* Config: reload dotenv before env substitution on runtime loads

* Test: isolate config env var regression from host state env

* fix: keep dotenv vars resolvable on runtime config reloads (#12748) (thanks @rodrigouroz)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-09 16:31:41 -06:00
quotentiroler
b40a7771e5 ci: imprpove warning for size check 2026-02-09 14:30:36 -08:00
quotentiroler
a172ff9ed2 docs: SEO and AI discoverability improvements
- Add description to docs.json for llms.txt blockquote summary
- Add title frontmatter to 10 docs files for llms.txt link text
- ci(docker): skip builds for docs-only changes
2026-02-09 14:20:56 -08:00
quotentiroler
e4a04f32e3 docs: add ci.md to Contributing navigation 2026-02-09 14:01:28 -08:00
Sk Akram
1cee5135e4 fix: preserve original filename for WhatsApp inbound documents (#12691)
* fix: preserve original filename for WhatsApp inbound documents

* fix: cover WhatsApp document filenames (#12691) (thanks @akramcodez)

* test: streamline inbound media waits (#12691) (thanks @akramcodez)

---------

Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-09 16:56:19 -05:00
quotentiroler
1074d13e4e Improve flagging in code analyzer 2026-02-09 13:41:36 -08:00
quotentiroler
1fad19008e fix: improve code-size gate output and duplicate detection, fix Windows path in source-display 2026-02-09 13:18:51 -08:00
max
65dae9a088 ci: add SwiftPM cache, fix Mintlify frontmatter (#12863)
* ci: add SwiftPM cache to macOS job, fix action description

* ci: fix frontmatter, remove DerivedData cache
2026-02-09 12:40:58 -08:00
quotentiroler
0b7e561434 ci: split format/lint into tiered gates with shared setup action 2026-02-09 12:24:11 -08:00
quotentiroler
dd25b96d0b ci: make code-size depend on checks-lint 2026-02-09 12:14:57 -08:00
quotentiroler
715e8b5440 ci: lint/format failures also block heavy jobs 2026-02-09 11:54:37 -08:00
quotentiroler
57a598c013 feat(ci): code-size gates heavy jobs, re-enable --strict 2026-02-09 11:53:29 -08:00
quotentiroler
de8eb2b29c feat(ci): also flag already-large files that grew larger 2026-02-09 11:51:51 -08:00
max
50b3d32d3c CI: add code-size check for files crossing LOC threshold (#12810)
* CI: add code-size check for files crossing LOC threshold

* feat(ci): add duplicate function detection to CI code-size check

The --compare-to mode now also detects new duplicate function names
introduced by a PR. Uses git diff to scope checks to changed files
only, keeping CI fast.

* fix(ci): address review feedback for code-size check

- Validate git ref upfront; exit 2 if ref doesn't exist
- Distinguish 'file missing at ref' from genuine git errors
- Explicitly fetch base branch ref in CI workflow
- Raise threshold from 700 to 1000 lines

* fix(ci): exclude Swabble, skills, .pi from code analysis

* update gitignore for pycache

* ci: make code-size check informational (no failure on violations)
2026-02-09 11:34:18 -08:00
Peter Steinberger
268094938b fix: reduce brew noise in onboarding 2026-02-09 13:27:21 -06:00
Peter Steinberger
9a765c9fb4 chore(mac): update appcast for 2026.2.9 2026-02-09 13:23:44 -06:00
max
ce71c7326c chore: add tsconfig.test.json for type-checking test files (#12828)
Adds a separate tsconfig that includes only *.test.ts files (which the main
tsconfig excludes). Available via 'pnpm tsgo:test' for incremental cleanup.

Not yet wired into 'pnpm check'  there are ~2.8k pre-existing errors in
test files that need to be fixed incrementally first.
2026-02-09 11:16:19 -08:00
Seb Slight
ec55583bb7 fix: align extension tests and fetch typing for gate stability (#12816) 2026-02-09 11:12:07 -08:00
356 changed files with 18385 additions and 4306 deletions

View File

@@ -1,6 +1,11 @@
# PR Review Instructions
# PR Workflow for Maintainers
Please read this in full and do not skip sections.
This is the single source of truth for the maintainer PR workflow.
## Triage order
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
## Working rule
@@ -9,9 +14,9 @@ Always pause between skills to evaluate technical direction, not just command su
These three skills must be used in order:
1. `review-pr`
2. `prepare-pr`
3. `merge-pr`
1. `review-pr` — review only, produce findings
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
3. `merge-pr` — squash-merge, verify MERGED state, clean up
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
@@ -31,6 +36,43 @@ Do not continue if you cannot verify the problem is real or test the fix.
- Harden changes. Always evaluate security impact and abuse paths.
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
## Rebase and conflict resolution
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
- During `prepare-pr`: rebase onto `main` is the first step, before fixing findings or running gates.
- If conflicts are complex or touch areas you do not understand, stop and escalate.
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
## Commit and changelog rules
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Co-contributor and clawtributors
- If we squash, add the PR author as a co-contributor in the commit.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
- When merging a PR from a new contributor: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
## Review mode vs landing mode
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
## Pre-review safety checks
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
## Unified workflow
Entry criteria:
@@ -72,7 +114,7 @@ Stop and escalate instead of continuing if:
Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main`, fix blocker/important findings, and run gates.
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
Expected output:
@@ -124,3 +166,4 @@ Maintainer checkpoint after merge:
- Were any refactors intentionally deferred and now need follow-up issue(s)?
- Did this reveal broader architecture or test gaps we should address?
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.

View File

@@ -46,3 +46,15 @@ Swabble/
Core/
Users/
vendor/
# Needed for building the Canvas A2UI bundle during Docker image builds.
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
!apps/shared/
!apps/shared/OpenClawKit/
!apps/shared/OpenClawKit/Tools/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
!vendor/a2ui/
!vendor/a2ui/renderers/
!vendor/a2ui/renderers/lit/
!vendor/a2ui/renderers/lit/**

View File

@@ -1,5 +1,70 @@
# Copy to .env and fill with your Twilio credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
# OpenClaw .env example
#
# Quick start:
# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons).
# 2) Fill only the values you use.
# 3) Keep real secrets out of git.
#
# Env-source precedence for environment variables (highest -> lowest):
# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block.
# Existing non-empty process env vars are not overridden by dotenv/config env loading.
# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json)
# are resolved separately from env loading and often take precedence over env fallbacks.
# -----------------------------------------------------------------------------
# Gateway auth + paths
# -----------------------------------------------------------------------------
# Recommended if the gateway binds beyond loopback.
OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
# Example generator: openssl rand -hex 32
# Optional alternative auth mode (use token OR password).
# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password
# Optional path overrides (defaults shown for reference).
# OPENCLAW_STATE_DIR=~/.openclaw
# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json
# OPENCLAW_HOME=~
# Optional: import missing keys from your login shell profile.
# OPENCLAW_LOAD_SHELL_ENV=1
# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000
# -----------------------------------------------------------------------------
# Model provider API keys (set at least one)
# -----------------------------------------------------------------------------
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=...
# OPENROUTER_API_KEY=sk-or-...
# Optional additional providers
# ZAI_API_KEY=...
# AI_GATEWAY_API_KEY=...
# MINIMAX_API_KEY=...
# SYNTHETIC_API_KEY=...
# -----------------------------------------------------------------------------
# Channels (only set what you enable)
# -----------------------------------------------------------------------------
# TELEGRAM_BOT_TOKEN=123456:ABCDEF...
# DISCORD_BOT_TOKEN=...
# SLACK_BOT_TOKEN=xoxb-...
# SLACK_APP_TOKEN=xapp-...
# Optional channel env fallbacks
# MATTERMOST_BOT_TOKEN=...
# MATTERMOST_URL=https://chat.example.com
# ZALO_BOT_TOKEN=...
# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:...
# -----------------------------------------------------------------------------
# Tools + voice/media (optional)
# -----------------------------------------------------------------------------
# BRAVE_API_KEY=...
# PERPLEXITY_API_KEY=pplx-...
# FIRECRAWL_API_KEY=...
# ELEVENLABS_API_KEY=...
# XI_API_KEY=... # alias for ElevenLabs
# DEEPGRAM_API_KEY=...

View File

@@ -1,8 +1,8 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Onboarding
url: https://discord.gg/clawd
about: New to Clawdbot? Join Discord for setup guidance from Krill in \#help.
about: New to OpenClaw? Join Discord for setup guidance from Krill in \#help.
- name: Support
url: https://discord.gg/clawd
about: Get help from Krill and the community on Discord in \#help.

View File

@@ -8,6 +8,9 @@ outputs:
docs_only:
description: "'true' if all changes are docs/markdown, 'false' otherwise"
value: ${{ steps.check.outputs.docs_only }}
docs_changed:
description: "'true' if any changed file is under docs/ or is markdown"
value: ${{ steps.check.outputs.docs_changed }}
runs:
using: composite
@@ -28,9 +31,18 @@ runs:
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Check if any changed file is a doc
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
if [ -n "$DOCS" ]; then
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
else
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
fi
# Check if all changed files are docs or markdown
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
if [ -z "$NON_DOCS" ]; then

View File

@@ -0,0 +1,83 @@
name: Setup Node environment
description: >
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
and run pnpm install. Requires actions/checkout to run first.
inputs:
node-version:
description: Node.js version to install.
required: false
default: "22.x"
pnpm-version:
description: pnpm version for corepack.
required: false
default: "10.23.0"
install-bun:
description: Whether to install Bun alongside Node.
required: false
default: "true"
frozen-lockfile:
description: Whether to use --frozen-lockfile for install.
required: false
default: "true"
runs:
using: composite
steps:
- name: Checkout submodules (retry)
shell: bash
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: ${{ inputs.pnpm-version }}
cache-key-suffix: "node22"
- name: Setup Bun
if: inputs.install-bun == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Runtime versions
shell: bash
run: |
node -v
npm -v
pnpm -v
if command -v bun &>/dev/null; then bun -v; fi
- name: Capture node path
shell: bash
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
- name: Install dependencies
shell: bash
env:
CI: "true"
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
LOCKFILE_FLAG=""
if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then
LOCKFILE_FLAG="--frozen-lockfile"
fi
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || \
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true

26
.github/labeler.yml vendored
View File

@@ -9,6 +9,11 @@
- "src/discord/**"
- "extensions/discord/**"
- "docs/channels/discord.md"
"channel: irc":
- changed-files:
- any-glob-to-any-file:
- "extensions/irc/**"
- "docs/channels/irc.md"
"channel: feishu":
- changed-files:
- any-glob-to-any-file:
@@ -79,6 +84,11 @@
- any-glob-to-any-file:
- "extensions/tlon/**"
- "docs/channels/tlon.md"
"channel: twitch":
- changed-files:
- any-glob-to-any-file:
- "extensions/twitch/**"
- "docs/channels/twitch.md"
"channel: voice-call":
- changed-files:
- any-glob-to-any-file:
@@ -226,3 +236,19 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/qwen-portal-auth/**"
"extensions: device-pair":
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
"extensions: minimax-portal-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/minimax-portal-auth/**"
"extensions: phone-control":
- changed-files:
- any-glob-to-any-file:
- "extensions/phone-control/**"
"extensions: talk-voice":
- changed-files:
- any-glob-to-any-file:
- "extensions/talk-voice/**"

View File

@@ -39,6 +39,11 @@ jobs:
message:
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: testflight",
close: true,
message: "Not available, build from source.",
},
{
label: "r: third-party-extension",
close: true,
@@ -60,10 +65,36 @@ jobs:
const title = issue.title ?? "";
const body = issue.body ?? "";
const haystack = `${title}\n${body}`.toLowerCase();
const hasLabel = (issue.labels ?? []).some((label) =>
const hasMoltbookLabel = (issue.labels ?? []).some((label) =>
typeof label === "string" ? label === "r: moltbook" : label?.name === "r: moltbook",
);
if (haystack.includes("moltbook") && !hasLabel) {
const hasTestflightLabel = (issue.labels ?? []).some((label) =>
typeof label === "string"
? label === "r: testflight"
: label?.name === "r: testflight",
);
const hasSecurityLabel = (issue.labels ?? []).some((label) =>
typeof label === "string" ? label === "security" : label?.name === "security",
);
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["security"],
});
return;
}
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["r: testflight"],
});
return;
}
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,

View File

@@ -16,6 +16,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -25,7 +26,7 @@ jobs:
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-only
uses: ./.github/actions/detect-docs-changes
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
# Push to main keeps broad coverage.
@@ -120,7 +121,7 @@ jobs:
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [docs-scope, changed-scope]
needs: [docs-scope, changed-scope, code-analysis, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
@@ -129,49 +130,10 @@ jobs:
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies (frozen)
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
install-bun: "false"
- name: Build dist
run: pnpm build
@@ -183,9 +145,10 @@ jobs:
path: dist/
retention-days: 1
install-check:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
# Validate npm pack contents after build (only on push to main, not PRs).
release-check:
needs: [docs-scope, build-artifacts]
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -193,61 +156,28 @@ jobs:
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: 22.x
check-latest: true
install-bun: "false"
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
name: dist-build
path: dist/
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies (frozen)
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Check release contents
run: pnpm release:check
checks:
needs: [docs-scope, changed-scope]
needs: [docs-scope, changed-scope, code-analysis, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: tsgo
command: pnpm tsgo
- runtime: node
task: test
command: pnpm canvas:a2ui:bundle && pnpm test
@@ -263,128 +193,77 @@ jobs:
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Runtime versions
run: |
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
# Lint and format always run, even on docs-only changes.
checks-lint:
# Types, lint, and format check.
check:
name: "check"
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: pnpm lint
- task: format
command: pnpm format
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Check types and lint and oxfmt
run: pnpm check
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_changed == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
node-version: 22.x
check-latest: true
submodules: false
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
- name: Check docs
run: pnpm check:docs
# Check for files that grew past LOC threshold in this PR (delta-only).
# On push events, all steps are skipped and the job passes (no-op).
# Heavy downstream jobs depend on this to fail fast on violations.
code-analysis:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
if: github.event_name == 'pull_request'
uses: actions/checkout@v4
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
fetch-depth: 0
submodules: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Python
if: github.event_name == 'pull_request'
uses: actions/setup-python@v5
with:
bun-version: latest
python-version: "3.12"
- name: Runtime versions
- name: Fetch base branch
if: github.event_name == 'pull_request'
run: git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}
- name: Check code file sizes
if: github.event_name == 'pull_request'
run: |
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
python scripts/analyze_code_files.py \
--compare-to origin/${{ github.base_ref }} \
--threshold 1000 \
--strict
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -412,7 +291,7 @@ jobs:
fi
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts]
needs: [docs-scope, changed-scope, build-artifacts, code-analysis, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-windows-2025
env:
@@ -461,19 +340,6 @@ jobs:
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Download dist artifact (lint lane)
if: matrix.task == 'lint'
uses: actions/download-artifact@v4
@@ -533,7 +399,7 @@ jobs:
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
needs: [docs-scope, changed-scope]
needs: [docs-scope, changed-scope, code-analysis, check]
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
runs-on: macos-latest
steps:
@@ -542,50 +408,10 @@ jobs:
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
# --- Node/pnpm setup (for TS tests) ---
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
install-bun: "false"
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
@@ -613,6 +439,14 @@ jobs:
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: ~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-swiftpm-
- name: Swift build (release)
run: |
set -euo pipefail
@@ -646,19 +480,6 @@ jobs:
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
@@ -811,7 +632,7 @@ jobs:
PY
android:
needs: [docs-scope, changed-scope]
needs: [docs-scope, changed-scope, code-analysis, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
@@ -828,19 +649,6 @@ jobs:
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Java
uses: actions/setup-java@v4
with:

View File

@@ -6,6 +6,12 @@ on:
- main
tags:
- "v*"
paths-ignore:
- "docs/**"
- "**/*.md"
- "**/*.mdx"
- ".agents/**"
- "skills/**"
env:
REGISTRY: ghcr.io
@@ -56,8 +62,8 @@ jobs:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
provenance: false
push: true
@@ -105,8 +111,8 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
provenance: false
push: true

View File

@@ -23,7 +23,7 @@ jobs:
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-only
uses: ./.github/actions/detect-docs-changes
install-smoke:
needs: [docs-scope]

View File

@@ -30,14 +30,26 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const association = context.payload.pull_request?.author_association;
if (!association) {
const login = context.payload.pull_request?.user?.login;
if (!login) {
return;
}
if (![
"MEMBER",
"OWNER",
].includes(association)) {
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: "maintainer",
username: login,
});
isMaintainer = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
if (!isMaintainer) {
return;
}
@@ -62,14 +74,26 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const association = context.payload.issue?.author_association;
if (!association) {
const login = context.payload.issue?.user?.login;
if (!login) {
return;
}
if (![
"MEMBER",
"OWNER",
].includes(association)) {
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: "maintainer",
username: login,
});
isMaintainer = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
if (!isMaintainer) {
return;
}

51
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Stale
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions: {}
jobs:
stale:
permissions:
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Mark stale issues and pull requests
uses: actions/stale@v9
with:
repo-token: ${{ steps.app-token.outputs.token }}
days-before-issue-stale: 7
days-before-issue-close: 5
days-before-pr-stale: 5
days-before-pr-close: 3
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
operations-per-run: 500
exempt-all-assignees: true
remove-stale-when-updated: true
stale-issue-message: |
This issue has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.
stale-pr-message: |
This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
close-issue-reason: not_planned
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ pnpm-lock.yaml
bun.lock
bun.lockb
coverage
__pycache__/
*.pyc
.tsbuildinfo
.pnpm-store
.worktrees/

View File

@@ -15,7 +15,7 @@
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage.
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
## Docs Linking (Mintlify)
@@ -60,6 +60,8 @@
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions
@@ -90,34 +92,18 @@
## Commit & Pull Request Guidelines
**Full maintainer PR workflow:** `.agents/skills/PR_WORKFLOW.md` -- triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the 3-step skill pipeline (`review-pr` > `prepare-pr` > `merge-pr`).
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
## Security & Configuration Tips
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
@@ -134,6 +120,7 @@
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.

View File

@@ -6,29 +6,47 @@ Docs: https://docs.openclaw.ai
### Added
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07.
- Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky.
- Tools: add Grok (xAI) as a `web_search` provider. (#12419) Thanks @tmchow.
- Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal.
- Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.
- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.
- Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy.
- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.
- Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing.
- Hooks: route webhook agent runs to specific `agentId`s, add `hooks.allowedAgentIds` controls, and fall back to default agent when unknown IDs are provided. (#13672) Thanks @BillChirico.
### Fixes
- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow.
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow.
- Discord: cap gateway reconnect attempts to avoid infinite retry loops. (#12230) Thanks @Yida-Dev.
- Telegram: render markdown spoilers with `<tg-spoiler>` HTML tags. (#11543) Thanks @ezhikkk.
- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale.
- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).
- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik.
- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
- Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.
- Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.
- Errors: avoid rewriting/swallowing normal assistant replies that mention error keywords by scoping `sanitizeUserFacingText` rewrites to error-context. (#12988) Thanks @Takhoffman.
- Config: re-hydrate state-dir `.env` during runtime config loads so `${VAR}` substitutions remain resolvable. (#12748) Thanks @rodrigouroz.
- Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman.
- Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
@@ -42,10 +60,15 @@ Docs: https://docs.openclaw.ai
- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj.
- Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
- Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07.
- Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07.
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
@@ -76,6 +99,8 @@ Docs: https://docs.openclaw.ai
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
@@ -126,6 +151,7 @@ Docs: https://docs.openclaw.ai
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
- Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai.
## 2026.2.2-3

View File

@@ -16,6 +16,9 @@ Welcome to the lobster tank! 🦞
- **Shadow** - Discord + Slack subsystem
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
@@ -25,6 +28,9 @@ Welcome to the lobster tank! 🦞
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
- GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
@@ -35,6 +41,7 @@ Welcome to the lobster tank! 🦞
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- Ensure CI checks pass
- Keep PRs focused (one thing per PR)
- Describe what & why
@@ -72,7 +79,33 @@ We are currently prioritizing:
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
- **UX**: Improving the onboarding wizard and error messages.
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills.
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
## Report a Vulnerability
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
### Required in Reports
1. **Title**
2. **Severity Assessment**
3. **Impact**
4. **Affected Component**
5. **Technical Reproduction**
6. **Demonstrated Impact**
7. **Environment**
8. **Remediation Advice**
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.

View File

@@ -24,7 +24,7 @@ COPY scripts ./scripts
RUN pnpm install --frozen-lockfile
COPY . .
RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build

View File

@@ -497,49 +497,53 @@ Special thanks to Adam Doppelt for lobster.bot.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a>
<a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a>
<a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
<a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
<a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a>
<a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/lsh411"><img src="https://avatars.githubusercontent.com/u/6801488?v=4&s=48" width="48" height="48" alt="lsh411" title="lsh411"/></a>
<a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="gut-puncture" title="gut-puncture"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="TinyTb" title="TinyTb"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/nicolasstanley"><img src="https://avatars.githubusercontent.com/u/60584925?v=4&s=48" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></a>
<a href="https://github.com/ironbyte-rgb"><img src="https://avatars.githubusercontent.com/u/230665944?v=4&s=48" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a>
<a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/jasonsschin"><img src="https://avatars.githubusercontent.com/u/1456889?v=4&s=48" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
<a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="18-RAJAT" title="18-RAJAT"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a>
<a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/fujiwara-tofu-shop"><img src="https://avatars.githubusercontent.com/u/259415332?v=4&s=48" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
<a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
<a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a>
<a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/therealZpoint-bot"><img src="https://avatars.githubusercontent.com/u/258706705?v=4&s=48" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></a>
<a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a> <a href="https://github.com/shatner"><img src="https://avatars.githubusercontent.com/u/17735435?v=4&s=48" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/robhparker"><img src="https://avatars.githubusercontent.com/u/7404740?v=4&s=48" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
<a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="mattqdev" title="mattqdev"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a>
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/spiceoogway"><img src="https://avatars.githubusercontent.com/u/105812383?v=4&s=48" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
<a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a>
<a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a>
<a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/bqcfjwhz85-arch"><img src="https://avatars.githubusercontent.com/u/239267175?v=4&s=48" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a>
<a href="https://github.com/search?q=damaozi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="damaozi" title="damaozi"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hclsys" title="hclsys"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a>
<a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a>
<a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mattezell"><img src="https://avatars.githubusercontent.com/u/361409?v=4&s=48" width="48" height="48" alt="mattezell" title="mattezell"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
<a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a>
<a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/deepsoumya617"><img src="https://avatars.githubusercontent.com/u/80877391?v=4&s=48" width="48" height="48" alt="deepsoumya617" title="deepsoumya617"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
<a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a>
<a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/ichbinlucaskim"><img src="https://avatars.githubusercontent.com/u/125564751?v=4&s=48" width="48" height="48" alt="ichbinlucaskim" title="ichbinlucaskim"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
<a href="https://github.com/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></a>
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Omar-Khaleel"><img src="https://avatars.githubusercontent.com/u/240748662?v=4&s=48" width="48" height="48" alt="Omar-Khaleel" title="Omar-Khaleel"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/rafelbev"><img src="https://avatars.githubusercontent.com/u/467120?v=4&s=48" width="48" height="48" alt="rafelbev" title="rafelbev"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a>
<a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/Shrinija17"><img src="https://avatars.githubusercontent.com/u/199155426?v=4&s=48" width="48" height="48" alt="Shrinija17" title="Shrinija17"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/stephenchen2025"><img src="https://avatars.githubusercontent.com/u/218387130?v=4&s=48" width="48" height="48" alt="stephenchen2025" title="stephenchen2025"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
<a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/wytheme"><img src="https://avatars.githubusercontent.com/u/5009358?v=4&s=48" width="48" height="48" alt="wytheme" title="wytheme"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
<a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a>
<a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a>
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
<a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a>
<a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a>
<a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="aerolalit" title="aerolalit"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/lsh411"><img src="https://avatars.githubusercontent.com/u/6801488?v=4&s=48" width="48" height="48" alt="lsh411" title="lsh411"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="gut-puncture" title="gut-puncture"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a>
<a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
<a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/jarvis89757"><img src="https://avatars.githubusercontent.com/u/258175441?v=4&s=48" width="48" height="48" alt="jarvis89757" title="jarvis89757"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="TinyTb" title="TinyTb"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/nicolasstanley"><img src="https://avatars.githubusercontent.com/u/60584925?v=4&s=48" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/ironbyte-rgb"><img src="https://avatars.githubusercontent.com/u/230665944?v=4&s=48" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="cdorsey" title="cdorsey"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a>
<a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a>
<a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/jasonsschin"><img src="https://avatars.githubusercontent.com/u/1456889?v=4&s=48" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/ThanhNguyxn"><img src="https://avatars.githubusercontent.com/u/74597207?v=4&s=48" width="48" height="48" alt="ThanhNguyxn" title="ThanhNguyxn"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="18-RAJAT" title="18-RAJAT"/></a>
<a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="kimitaka" title="kimitaka"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="yuting0624" title="yuting0624"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/baccula"><img src="https://avatars.githubusercontent.com/u/22080883?v=4&s=48" width="48" height="48" alt="baccula" title="baccula"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="manikv12" title="manikv12"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="sbking" title="sbking"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/fujiwara-tofu-shop"><img src="https://avatars.githubusercontent.com/u/259415332?v=4&s=48" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></a>
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/calvin-hpnet"><img src="https://avatars.githubusercontent.com/u/258432838?v=4&s=48" width="48" height="48" alt="calvin-hpnet" title="calvin-hpnet"/></a> <a href="https://github.com/gitpds"><img src="https://avatars.githubusercontent.com/u/78130276?v=4&s=48" width="48" height="48" alt="gitpds" title="gitpds"/></a> <a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a>
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a>
<a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/jabezborja"><img src="https://avatars.githubusercontent.com/u/64759159?v=4&s=48" width="48" height="48" alt="jabezborja" title="jabezborja"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a>
<a href="https://github.com/patrickshao"><img src="https://avatars.githubusercontent.com/u/5953037?v=4&s=48" width="48" height="48" alt="patrickshao" title="patrickshao"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a>
<a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a>
<a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="orenyomtov" title="orenyomtov"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/abhijeet117"><img src="https://avatars.githubusercontent.com/u/192859219?v=4&s=48" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/hudson-rivera"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="hudson-rivera" title="hudson-rivera"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="itsjling" title="itsjling"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="mattqdev" title="mattqdev"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a>
<a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
<a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/bravostation"><img src="https://avatars.githubusercontent.com/u/257991910?v=4&s=48" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/search?q=damaozi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="damaozi" title="damaozi"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a>
<a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="liuxiaopai-ai" title="liuxiaopai-ai"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a>
<a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a>
<a href="https://github.com/bqcfjwhz85-arch"><img src="https://avatars.githubusercontent.com/u/239267175?v=4&s=48" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/danielcadenhead"><img src="https://avatars.githubusercontent.com/u/195258443?v=4&s=48" width="48" height="48" alt="danielcadenhead" title="danielcadenhead"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hclsys" title="hclsys"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mattezell"><img src="https://avatars.githubusercontent.com/u/361409?v=4&s=48" width="48" height="48" alt="mattezell" title="mattezell"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
<a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="RayBB" title="RayBB"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a>
<a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a>
<a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a>
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Claude%20Code"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Claude Code" title="Claude Code"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a>
<a href="https://github.com/deepsoumya617"><img src="https://avatars.githubusercontent.com/u/80877391?v=4&s=48" width="48" height="48" alt="deepsoumya617" title="deepsoumya617"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
<a href="https://github.com/fredheir"><img src="https://avatars.githubusercontent.com/u/3304869?v=4&s=48" width="48" height="48" alt="fredheir" title="fredheir"/></a> <a href="https://github.com/Fronut"><img src="https://avatars.githubusercontent.com/u/165925262?v=4&s=48" width="48" height="48" alt="Fronut" title="Fronut"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a>
<a href="https://github.com/ichbinlucaskim"><img src="https://avatars.githubusercontent.com/u/125564751?v=4&s=48" width="48" height="48" alt="ichbinlucaskim" title="ichbinlucaskim"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a>
<a href="https://github.com/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></a> <a href="https://github.com/kossoy"><img src="https://avatars.githubusercontent.com/u/51094?v=4&s=48" width="48" height="48" alt="kossoy" title="kossoy"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loganaden"><img src="https://avatars.githubusercontent.com/u/1688420?v=4&s=48" width="48" height="48" alt="loganaden" title="loganaden"/></a> <a href="https://github.com/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></a> <a href="https://github.com/markusbkoch"><img src="https://avatars.githubusercontent.com/u/34865315?v=4&s=48" width="48" height="48" alt="markusbkoch" title="markusbkoch"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/search?q=minghinmatthewlam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=mudrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/search?q=myfunc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="myfunc" title="myfunc"/></a>
<a href="https://github.com/mylukin"><img src="https://avatars.githubusercontent.com/u/1021019?v=4&s=48" width="48" height="48" alt="mylukin" title="mylukin"/></a> <a href="https://github.com/nathanbosse"><img src="https://avatars.githubusercontent.com/u/4040669?v=4&s=48" width="48" height="48" alt="nathanbosse" title="nathanbosse"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Omar-Khaleel"><img src="https://avatars.githubusercontent.com/u/240748662?v=4&s=48" width="48" height="48" alt="Omar-Khaleel" title="Omar-Khaleel"/></a> <a href="https://github.com/ozgur-polat"><img src="https://avatars.githubusercontent.com/u/26483942?v=4&s=48" width="48" height="48" alt="ozgur-polat" title="ozgur-polat"/></a> <a href="https://github.com/search?q=pasogott"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/search?q=plum-dawg"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/search?q=pookNast"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pookNast" title="pookNast"/></a>
<a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/search?q=rafaelreis-r"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/rafelbev"><img src="https://avatars.githubusercontent.com/u/467120?v=4&s=48" width="48" height="48" alt="rafelbev" title="rafelbev"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=robhparker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/rohansachinpatil"><img src="https://avatars.githubusercontent.com/u/172933149?v=4&s=48" width="48" height="48" alt="rohansachinpatil" title="rohansachinpatil"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/ryancnelson"><img src="https://avatars.githubusercontent.com/u/347171?v=4&s=48" width="48" height="48" alt="ryancnelson" title="ryancnelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/search?q=seans-openclawbot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="seans-openclawbot" title="seans-openclawbot"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/search?q=shatner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/Shrinija17"><img src="https://avatars.githubusercontent.com/u/199155426?v=4&s=48" width="48" height="48" alt="Shrinija17" title="Shrinija17"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=spiceoogway"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a> <a href="https://github.com/stephenchen2025"><img src="https://avatars.githubusercontent.com/u/218387130?v=4&s=48" width="48" height="48" alt="stephenchen2025" title="stephenchen2025"/></a> <a href="https://github.com/search?q=succ985"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="succ985" title="succ985"/></a> <a href="https://github.com/Suvink"><img src="https://avatars.githubusercontent.com/u/10671497?v=4&s=48" width="48" height="48" alt="Suvink" title="Suvink"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=tewatia"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/search?q=therealZpoint-bot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=uos-status"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/vcastellm"><img src="https://avatars.githubusercontent.com/u/47026?v=4&s=48" width="48" height="48" alt="vcastellm" title="vcastellm"/></a> <a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/search?q=void"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="void" title="void"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a>
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/wytheme"><img src="https://avatars.githubusercontent.com/u/5009358?v=4&s=48" width="48" height="48" alt="wytheme" title="wytheme"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/search?q=zhixian"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="zhixian" title="zhixian"/></a>
<a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a>
<a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

View File

@@ -4,9 +4,31 @@ If you believe you've found a security issue in OpenClaw, please report it priva
## Reporting
For full reporting instructions - including which repo to report to and how - see our [Trust page](https://trust.openclaw.ai).
Report vulnerabilities directly to the repository where the issue lives:
Include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
### Required in Reports
1. **Title**
2. **Severity Assessment**
3. **Impact**
4. **Affected Component**
5. **Technical Reproduction**
6. **Demonstrated Impact**
7. **Environment**
8. **Remediation Advice**
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
## Security & Trust

View File

@@ -2,6 +2,62 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.2.9</title>
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>9194</sparkle:version>
<sparkle:shortVersionString>2026.2.9</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.9</h2>
<h3>Added</h3>
<ul>
<li>iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.</li>
<li>Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.</li>
<li>Plugins: device pairing + phone control plugins (Telegram <code>/pair</code>, iOS/Android node controls). (#11755) Thanks @mbelinky.</li>
<li>Tools: add Grok (xAI) as a <code>web_search</code> provider. (#12419) Thanks @tmchow.</li>
<li>Gateway: add agent management RPC methods for the web UI (<code>agents.create</code>, <code>agents.update</code>, <code>agents.delete</code>). (#11045) Thanks @advaitpaliwal.</li>
<li>Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.</li>
<li>Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.</li>
<li>Paths: add <code>OPENCLAW_HOME</code> for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.</li>
<li>Telegram: recover proactive sends when stale topic thread IDs are used by retrying without <code>message_thread_id</code>. (#11620)</li>
<li>Telegram: render markdown spoilers with <code><tg-spoiler></code> HTML tags. (#11543) Thanks @ezhikkk.</li>
<li>Telegram: truncate command registration to 100 entries to avoid <code>BOT_COMMANDS_TOO_MUCH</code> failures on startup. (#12356) Thanks @arosstale.</li>
<li>Telegram: match DM <code>allowFrom</code> against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.</li>
<li>Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).</li>
<li>Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.</li>
<li>Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.</li>
<li>Tools/web_search: include provider-specific settings in the web search cache key, and pass <code>inlineCitations</code> for Grok. (#12419) Thanks @tmchow.</li>
<li>Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.</li>
<li>Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.</li>
<li>Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.</li>
<li>Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session <code>parentId</code> chain so agents can remember again. (#12283) Thanks @Takhoffman.</li>
<li>Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.</li>
<li>Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.</li>
<li>Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.</li>
<li>Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.</li>
<li>Cron tool: recover flat params when LLM omits the <code>job</code> wrapper for add requests. (#12124) Thanks @tyler6204.</li>
<li>Gateway/CLI: when <code>gateway.bind=lan</code>, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.</li>
<li>Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.</li>
<li>Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.</li>
<li>Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.</li>
<li>Config: clamp <code>maxTokens</code> to <code>contextWindow</code> to prevent invalid model configs. (#5516) Thanks @lailoo.</li>
<li>Thinking: allow xhigh for <code>github-copilot/gpt-5.2-codex</code> and <code>github-copilot/gpt-5.2</code>. (#11646) Thanks @LatencyTDH.</li>
<li>Discord: support forum/media thread-create starter messages, wire <code>message thread create --message</code>, and harden routing. (#10062) Thanks @jarvis89757.</li>
<li>Paths: structurally resolve <code>OPENCLAW_HOME</code>-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.</li>
<li>Memory: set Voyage embeddings <code>input_type</code> for improved retrieval. (#10818) Thanks @mcinteerj.</li>
<li>Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.</li>
<li>Media understanding: recognize <code>.caf</code> audio attachments for transcription. (#10982) Thanks @succ985.</li>
<li>State dir: honor <code>OPENCLAW_STATE_DIR</code> for default device identity and canvas storage paths. (#4824) Thanks @kossoy.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
</item>
<item>
<title>2026.2.3</title>
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
@@ -96,71 +152,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.2/OpenClaw-2026.2.2.zip" length="22519052" type="application/octet-stream" sparkle:edSignature="a6viD+aS5EfY/RkPIPMfoQQNkJCk6QTdV5WobXFxyYwURskUm8/nXTHVXsCh1c5+0WKUnmlDIyf0i+6IWiavAA=="/>
</item>
<item>
<title>2026.2.1</title>
<pubDate>Mon, 02 Feb 2026 03:53:03 -0800</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>8650</sparkle:version>
<sparkle:shortVersionString>2026.2.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.1</h2>
<h3>Changes</h3>
<ul>
<li>Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)</li>
<li>Telegram: use shared pairing store. (#6127) Thanks @obviyus.</li>
<li>Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.</li>
<li>Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.</li>
<li>Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).</li>
<li>Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.</li>
<li>Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)</li>
<li>Auth: update MiniMax OAuth hint + portal auth note copy.</li>
<li>Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.</li>
<li>Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.</li>
<li>Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.</li>
<li>Web UI: refine chat layout + extend session active duration.</li>
<li>CI: add formal conformance + alias consistency checks. (#5723, #5807)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Plugins: validate plugin/hook install paths and reject traversal-like names.</li>
<li>Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.</li>
<li>Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.</li>
<li>Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)</li>
<li>Streaming: stabilize partial streaming filters.</li>
<li>Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.</li>
<li>Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).</li>
<li>Tools: treat <code>"*"</code> tool allowlist entries as valid to avoid spurious unknown-entry warnings.</li>
<li>Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)</li>
<li>Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.</li>
<li>Lint: satisfy curly rule after import sorting. (#6310)</li>
<li>Process: resolve Windows <code>spawn()</code> failures for npm-family CLIs by appending <code>.cmd</code> when needed. (#5815) Thanks @thejhinvirtuoso.</li>
<li>Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.</li>
<li>Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)</li>
<li>Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)</li>
<li>Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).</li>
<li>Agents: ensure OpenRouter attribution headers apply in the embedded runner.</li>
<li>Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.</li>
<li>System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)</li>
<li>Agents: fix Pi prompt template argument syntax. (#6543)</li>
<li>Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)</li>
<li>Teams: gate media auth retries.</li>
<li>Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.</li>
<li>Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.</li>
<li>TUI: prevent crash when searching with digits in the model selector.</li>
<li>Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.</li>
<li>Browser: secure Chrome extension relay CDP sessions.</li>
<li>Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.</li>
<li>fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.</li>
<li>Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)</li>
<li>Security: restrict MEDIA path extraction to prevent LFI. (#4930)</li>
<li>Security: validate message-tool filePath/path against sandbox root. (#6398)</li>
<li>Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.</li>
<li>Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.</li>
<li>Security: enforce Twitch <code>allowFrom</code> allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.1/OpenClaw-2026.2.1.zip" length="22458919" type="application/octet-stream" sparkle:edSignature="kA/8VQlVdtYphcB1iuFrhWczwWKgkVZMfDfQ7T9WD405D8JKTv5CZ1n8lstIVkpk4xog3UhrfaaoTG8Bf8DMAQ=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -56,7 +56,6 @@ COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local -a mounts=("$@")
local mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
@@ -71,7 +70,7 @@ YAML
printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
for mount in "$@"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
@@ -86,7 +85,7 @@ YAML
printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
for mount in "$@"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
@@ -111,7 +110,12 @@ if [[ -n "$EXTRA_MOUNTS" ]]; then
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
else
write_extra_compose "$HOME_VOLUME_NAME"
fi
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
@@ -129,7 +133,9 @@ upsert_env() {
local -a keys=("$@")
local tmp
tmp="$(mktemp)"
declare -A seen=()
# Use a delimited string instead of an associative array so the script
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
local seen=" "
if [[ -f "$file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
@@ -138,7 +144,7 @@ upsert_env() {
for k in "${keys[@]}"; do
if [[ "$key" == "$k" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
seen["$k"]=1
seen="$seen$k "
replaced=true
break
fi
@@ -150,7 +156,7 @@ upsert_env() {
fi
for k in "${keys[@]}"; do
if [[ -z "${seen[$k]:-}" ]]; then
if [[ "$seen" != *" $k "* ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
fi
done

View File

@@ -18,6 +18,10 @@ Gateway can expose a small HTTP webhook endpoint for external triggers.
enabled: true,
token: "shared-secret",
path: "/hooks",
// Optional: restrict explicit `agentId` routing to this allowlist.
// Omit or include "*" to allow any agent.
// Set [] to deny all explicit `agentId` routing.
allowedAgentIds: ["hooks", "main"],
},
}
```
@@ -61,6 +65,7 @@ Payload:
{
"message": "Run this",
"name": "Email",
"agentId": "hooks",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
@@ -74,6 +79,7 @@ Payload:
- `message` **required** (string): The prompt or message for the agent to process.
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
@@ -104,6 +110,8 @@ Mapping options (summary):
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp).
- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent.
- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing.
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
(dangerous; only for trusted internal sources).
- `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`.
@@ -157,6 +165,7 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection.
- Avoid including sensitive raw payloads in webhook logs.
- Hook payloads are treated as untrusted and wrapped with safety boundaries by default.
If you must disable this for a specific hook, set `allowUnsafeExternalContent: true`

View File

@@ -356,7 +356,7 @@ ack reaction after the bot replies.
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
- `presence` (bot status/activity, default `false`)
- `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`.
- `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`, `cleanupAfterResolve`.
Reaction notifications use `guilds.<id>.reactionNotifications`:

View File

@@ -16,6 +16,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.

234
docs/channels/irc.md Normal file
View File

@@ -0,0 +1,234 @@
---
title: IRC
description: Connect OpenClaw to IRC channels and direct messages.
---
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`.
## Quick start
1. Enable IRC config in `~/.openclaw/openclaw.json`.
2. Set at least:
```json
{
"channels": {
"irc": {
"enabled": true,
"host": "irc.libera.chat",
"port": 6697,
"tls": true,
"nick": "openclaw-bot",
"channels": ["#openclaw"]
}
}
}
```
3. Start/restart gateway:
```bash
openclaw gateway run
```
## Security defaults
- `channels.irc.dmPolicy` defaults to `"pairing"`.
- `channels.irc.groupPolicy` defaults to `"allowlist"`.
- With `groupPolicy="allowlist"`, set `channels.irc.groups` to define allowed channels.
- Use TLS (`channels.irc.tls=true`) unless you intentionally accept plaintext transport.
## Access control
There are two separate “gates” for IRC channels:
1. **Channel access** (`groupPolicy` + `groups`): whether the bot accepts messages from a channel at all.
2. **Sender access** (`groupAllowFrom` / per-channel `groups["#channel"].allowFrom`): who is allowed to trigger the bot inside that channel.
Config keys:
- DM allowlist (DM sender access): `channels.irc.allowFrom`
- Group sender allowlist (channel sender access): `channels.irc.groupAllowFrom`
- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]`
- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**)
Allowlist entries can use nick or `nick!user@host` forms.
### Common gotcha: `allowFrom` is for DMs, not channels
If you see logs like:
- `irc: drop group sender alice!ident@host (policy=allowlist)`
…it means the sender wasn’t allowed for **group/channel** messages. Fix it by either:
- setting `channels.irc.groupAllowFrom` (global for all channels), or
- setting per-channel sender allowlists: `channels.irc.groups["#channel"].allowFrom`
Example (allow anyone in `#tuirc-dev` to talk to the bot):
```json5
{
channels: {
irc: {
groupPolicy: "allowlist",
groups: {
"#tuirc-dev": { allowFrom: ["*"] },
},
},
},
}
```
## Reply triggering (mentions)
Even if a channel is allowed (via `groupPolicy` + `groups`) and the sender is allowed, OpenClaw defaults to **mention-gating** in group contexts.
That means you may see logs like `drop channel … (missing-mention)` unless the message includes a mention pattern that matches the bot.
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
```json5
{
channels: {
irc: {
groupPolicy: "allowlist",
groups: {
"#tuirc-dev": {
requireMention: false,
allowFrom: ["*"],
},
},
},
},
}
```
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
```json5
{
channels: {
irc: {
groupPolicy: "open",
groups: {
"*": { requireMention: false, allowFrom: ["*"] },
},
},
},
}
```
## Security note (recommended for public channels)
If you allow `allowFrom: ["*"]` in a public channel, anyone can prompt the bot.
To reduce risk, restrict tools for that channel.
### Same tools for everyone in the channel
```json5
{
channels: {
irc: {
groups: {
"#tuirc-dev": {
allowFrom: ["*"],
tools: {
deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"],
},
},
},
},
},
}
```
### Different tools per sender (owner gets more power)
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
```json5
{
channels: {
irc: {
groups: {
"#tuirc-dev": {
allowFrom: ["*"],
toolsBySender: {
"*": {
deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"],
},
eigen: {
deny: ["gateway", "nodes", "cron"],
},
},
},
},
},
},
}
```
Notes:
- `toolsBySender` keys can be a nick (e.g. `"eigen"`) or a full hostmask (`"eigen!~eigen@174.127.248.171"`) for stronger identity matching.
- The first matching sender policy wins; `"*"` is the wildcard fallback.
For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups).
## NickServ
To identify with NickServ after connect:
```json
{
"channels": {
"irc": {
"nickserv": {
"enabled": true,
"service": "NickServ",
"password": "your-nickserv-password"
}
}
}
}
```
Optional one-time registration on connect:
```json
{
"channels": {
"irc": {
"nickserv": {
"register": true,
"registerEmail": "bot@example.com"
}
}
}
}
```
Disable `register` after the nick is registered to avoid repeated REGISTER attempts.
## Environment variables
Default account supports:
- `IRC_HOST`
- `IRC_PORT`
- `IRC_TLS`
- `IRC_NICK`
- `IRC_USERNAME`
- `IRC_REALNAME`
- `IRC_PASSWORD`
- `IRC_CHANNELS` (comma-separated)
- `IRC_NICKSERV_PASSWORD`
- `IRC_NICKSERV_REGISTER_EMAIL`
## Troubleshooting
- If the bot connects but never replies in channels, verify `channels.irc.groups` **and** whether mention-gating is dropping messages (`missing-mention`). If you want it to reply without pings, set `requireMention:false` for the channel.
- If login fails, verify nick availability and server password.
- If TLS fails on a custom network, verify host/port and certificate setup.

63
docs/ci.md Normal file
View File

@@ -0,0 +1,63 @@
---
title: CI Pipeline
description: How the OpenClaw CI pipeline works
---
# CI Pipeline
The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only docs or native code changed.
## Job Overview
| Job | Purpose | When it runs |
| ----------------- | ----------------------------------------------- | ------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Non-docs changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
## Fail-Fast Order
Jobs are ordered so cheap checks fail before expensive ones run:
1. `docs-scope` + `code-analysis` + `check` (parallel, ~1-2 min)
2. `build-artifacts` (blocked on above)
3. `checks`, `checks-windows`, `macos`, `android` (blocked on build)
## Code Analysis
The `code-analysis` job runs `scripts/analyze_code_files.py` on PRs to enforce code quality:
- **LOC threshold**: Files that grow past 1000 lines fail the build
- **Delta-only**: Only checks files changed in the PR, not the entire codebase
- **Push to main**: Skipped (job passes as no-op) so merges aren't blocked
When `--strict` is set, violations block all downstream jobs. This catches bloated files early before expensive tests run.
Excluded directories: `node_modules`, `dist`, `vendor`, `.git`, `coverage`, `Swabble`, `skills`, `.pi`
## Runners
| Runner | Jobs |
| ------------------------------- | ----------------------------- |
| `blacksmith-4vcpu-ubuntu-2404` | Most Linux jobs |
| `blacksmith-4vcpu-windows-2025` | `checks-windows` |
| `macos-latest` | `macos`, `ios` |
| `ubuntu-latest` | Scope detection (lightweight) |
## Local Equivalents
```bash
pnpm check # types + lint + format
pnpm test # vitest tests
pnpm check:docs # docs format + lint + broken links
pnpm release:check # validate npm pack
```

View File

@@ -12,6 +12,7 @@ Interactive onboarding wizard (local or remote Gateway setup).
## Related guides
- CLI onboarding hub: [Onboarding Wizard (CLI)](/start/wizard)
- Onboarding overview: [Onboarding Overview](/start/onboarding-overview)
- CLI onboarding reference: [CLI Onboarding Reference](/start/wizard-cli-reference)
- CLI automation: [CLI Automation](/start/wizard-cli-automation)
- macOS onboarding: [Onboarding (macOS App)](/start/onboarding)
@@ -30,6 +31,8 @@ Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup).
- Custom Provider: connect any OpenAI or Anthropic compatible endpoint,
including hosted providers not listed. Use Unknown to auto-detect.
## Common follow-up commands

View File

@@ -1,4 +1,5 @@
---
title: "Memory"
summary: "How OpenClaw memory works (workspace files + automatic memory flush)"
read_when:
- You want the memory file layout and workflow
@@ -84,6 +85,8 @@ Defaults:
- Enabled by default.
- Watches memory files for changes (debounced).
- Configure memory search under `agents.defaults.memorySearch` (not top-level
`memorySearch`).
- Uses remote embeddings by default. If `memorySearch.provider` is not set, OpenClaw auto-selects:
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
@@ -131,10 +134,13 @@ out to QMD for retrieval. Key points:
(plus default workspace memory files), then `qmd update` + `qmd embed` run
on boot and on a configurable interval (`memory.qmd.update.interval`,
default 5 m).
- The gateway now initializes the QMD manager on startup, so periodic update
timers are armed even before the first `memory_search` call.
- Boot refresh now runs in the background by default so chat startup is not
blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
blocking behavior.
- Searches run via `qmd query --json`. If QMD fails or the binary is missing,
- Searches run via `qmd query --json`, scoped to OpenClaw-managed collections.
If QMD fails or the binary is missing,
OpenClaw automatically falls back to the builtin SQLite manager so memory tools
keep working.
- OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is
@@ -301,9 +307,9 @@ Fallbacks:
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini):
Batch indexing (OpenAI + Gemini + Voyage):
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- Disabled by default. Set `agents.defaults.memorySearch.remote.batch.enabled = true` to enable for large-corpus indexing (OpenAI, Gemini, and Voyage).
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.

View File

@@ -1,4 +1,5 @@
---
title: "Session Pruning"
summary: "Session pruning: tool-result trimming to reduce context bloat"
read_when:
- You want to reduce LLM context growth from tool outputs

View File

@@ -59,11 +59,24 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
- `USER.md`
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (only on brand-new workspaces)
- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected)
All of these files are **injected into the context window** on every turn, which
means they consume tokens. Keep them concise — especially `MEMORY.md`, which can
grow over time and lead to unexpectedly high context usage and more frequent
compaction.
> **Note:** `memory/*.md` daily files are **not** injected automatically. They
> are accessed on demand via the `memory_search` and `memory_get` tools, so they
> do not count against the context window unless the model explicitly reads them.
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
short missing-file marker.
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
are filtered out to keep the sub-agent context small).
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).

View File

@@ -1,6 +1,7 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "OpenClaw",
"description": "Self-hosted gateway that connects WhatsApp, Telegram, Discord, iMessage, and more to AI coding agents. Run a single Gateway process on your own machine and message your AI assistant from anywhere.",
"theme": "mint",
"icons": {
"library": "lucide"
@@ -801,7 +802,12 @@
},
{
"group": "First steps",
"pages": ["start/getting-started", "start/wizard", "start/onboarding"]
"pages": [
"start/getting-started",
"start/onboarding-overview",
"start/wizard",
"start/onboarding"
]
},
{
"group": "Guides",
@@ -857,6 +863,7 @@
"channels/telegram",
"channels/grammy",
"channels/discord",
"channels/irc",
"channels/slack",
"channels/feishu",
"channels/googlechat",
@@ -1231,6 +1238,16 @@
{
"group": "Release notes",
"pages": ["reference/RELEASING", "reference/test"]
},
{
"group": "Experiments",
"pages": [
"experiments/onboarding-config-protocol",
"experiments/plans/cron-add-hardening",
"experiments/plans/group-policy-hardening",
"experiments/research/memory",
"experiments/proposals/model-config"
]
}
]
},
@@ -1263,7 +1280,7 @@
},
{
"group": "Contributing",
"pages": ["help/submitting-a-pr", "help/submitting-an-issue"]
"pages": ["help/submitting-a-pr", "help/submitting-an-issue", "ci"]
},
{
"group": "Docs meta",
@@ -1750,6 +1767,16 @@
{
"group": "发布说明",
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
},
{
"group": "实验性功能",
"pages": [
"zh-CN/experiments/onboarding-config-protocol",
"zh-CN/experiments/plans/cron-add-hardening",
"zh-CN/experiments/plans/group-policy-hardening",
"zh-CN/experiments/research/memory",
"zh-CN/experiments/proposals/model-config"
]
}
]
},

View File

@@ -0,0 +1,229 @@
---
summary: "Plan: isolate browser act:evaluate from Playwright queue using CDP, with end-to-end deadlines and safer ref resolution"
owner: "openclaw"
status: "draft"
last_updated: "2026-02-10"
title: "Browser Evaluate CDP Refactor"
---
# Browser Evaluate CDP Refactor Plan
## Context
`act:evaluate` executes user provided JavaScript in the page. Today it runs via Playwright
(`page.evaluate` or `locator.evaluate`). Playwright serializes CDP commands per page, so a
stuck or long running evaluate can block the page command queue and make every later action
on that tab look "stuck".
PR #13498 adds a pragmatic safety net (bounded evaluate, abort propagation, and best-effort
recovery). This document describes a larger refactor that makes `act:evaluate` inherently
isolated from Playwright so a stuck evaluate cannot wedge normal Playwright operations.
## Goals
- `act:evaluate` cannot permanently block later browser actions on the same tab.
- Timeouts are single source of truth end to end so a caller can rely on a budget.
- Abort and timeout are treated the same way across HTTP and in-process dispatch.
- Element targeting for evaluate is supported without switching everything off Playwright.
- Maintain backward compatibility for existing callers and payloads.
## Non-goals
- Replace all browser actions (click, type, wait, etc.) with CDP implementations.
- Remove the existing safety net introduced in PR #13498 (it remains a useful fallback).
- Introduce new unsafe capabilities beyond the existing `browser.evaluateEnabled` gate.
- Add process isolation (worker process/thread) for evaluate. If we still see hard to recover
stuck states after this refactor, that is a follow-up idea.
## Current Architecture (Why It Gets Stuck)
At a high level:
- Callers send `act:evaluate` to the browser control service.
- The route handler calls into Playwright to execute the JavaScript.
- Playwright serializes page commands, so an evaluate that never finishes blocks the queue.
- A stuck queue means later click/type/wait operations on the tab can appear to hang.
## Proposed Architecture
### 1. Deadline Propagation
Introduce a single budget concept and derive everything from it:
- Caller sets `timeoutMs` (or a deadline in the future).
- The outer request timeout, route handler logic, and the execution budget inside the page
all use the same budget, with small headroom where needed for serialization overhead.
- Abort is propagated as an `AbortSignal` everywhere so cancellation is consistent.
Implementation direction:
- Add a small helper (for example `createBudget({ timeoutMs, signal })`) that returns:
- `signal`: the linked AbortSignal
- `deadlineAtMs`: absolute deadline
- `remainingMs()`: remaining budget for child operations
- Use this helper in:
- `src/browser/client-fetch.ts` (HTTP and in-process dispatch)
- `src/node-host/runner.ts` (proxy path)
- browser action implementations (Playwright and CDP)
### 2. Separate Evaluate Engine (CDP Path)
Add a CDP based evaluate implementation that does not share Playwright's per page command
queue. The key property is that the evaluate transport is a separate WebSocket connection
and a separate CDP session attached to the target.
Implementation direction:
- New module, for example `src/browser/cdp-evaluate.ts`, that:
- Connects to the configured CDP endpoint (browser level socket).
- Uses `Target.attachToTarget({ targetId, flatten: true })` to get a `sessionId`.
- Runs either:
- `Runtime.evaluate` for page level evaluate, or
- `DOM.resolveNode` plus `Runtime.callFunctionOn` for element evaluate.
- On timeout or abort:
- Sends `Runtime.terminateExecution` best-effort for the session.
- Closes the WebSocket and returns a clear error.
Notes:
- This still executes JavaScript in the page, so termination can have side effects. The win
is that it does not wedge the Playwright queue, and it is cancelable at the transport
layer by killing the CDP session.
### 3. Ref Story (Element Targeting Without A Full Rewrite)
The hard part is element targeting. CDP needs a DOM handle or `backendDOMNodeId`, while
today most browser actions use Playwright locators based on refs from snapshots.
Recommended approach: keep existing refs, but attach an optional CDP resolvable id.
#### 3.1 Extend Stored Ref Info
Extend the stored role ref metadata to optionally include a CDP id:
- Today: `{ role, name, nth }`
- Proposed: `{ role, name, nth, backendDOMNodeId?: number }`
This keeps all existing Playwright based actions working and allows CDP evaluate to accept
the same `ref` value when the `backendDOMNodeId` is available.
#### 3.2 Populate backendDOMNodeId At Snapshot Time
When producing a role snapshot:
1. Generate the existing role ref map as today (role, name, nth).
2. Fetch the AX tree via CDP (`Accessibility.getFullAXTree`) and compute a parallel map of
`(role, name, nth) -> backendDOMNodeId` using the same duplicate handling rules.
3. Merge the id back into the stored ref info for the current tab.
If mapping fails for a ref, leave `backendDOMNodeId` undefined. This makes the feature
best-effort and safe to roll out.
#### 3.3 Evaluate Behavior With Ref
In `act:evaluate`:
- If `ref` is present and has `backendDOMNodeId`, run element evaluate via CDP.
- If `ref` is present but has no `backendDOMNodeId`, fall back to the Playwright path (with
the safety net).
Optional escape hatch:
- Extend the request shape to accept `backendDOMNodeId` directly for advanced callers (and
for debugging), while keeping `ref` as the primary interface.
### 4. Keep A Last Resort Recovery Path
Even with CDP evaluate, there are other ways to wedge a tab or a connection. Keep the
existing recovery mechanisms (terminate execution + disconnect Playwright) as a last resort
for:
- legacy callers
- environments where CDP attach is blocked
- unexpected Playwright edge cases
## Implementation Plan (Single Iteration)
### Deliverables
- A CDP based evaluate engine that runs outside the Playwright per-page command queue.
- A single end-to-end timeout/abort budget used consistently by callers and handlers.
- Ref metadata that can optionally carry `backendDOMNodeId` for element evaluate.
- `act:evaluate` prefers the CDP engine when possible and falls back to Playwright when not.
- Tests that prove a stuck evaluate does not wedge later actions.
- Logs/metrics that make failures and fallbacks visible.
### Implementation Checklist
1. Add a shared "budget" helper to link `timeoutMs` + upstream `AbortSignal` into:
- a single `AbortSignal`
- an absolute deadline
- a `remainingMs()` helper for downstream operations
2. Update all caller paths to use that helper so `timeoutMs` means the same thing everywhere:
- `src/browser/client-fetch.ts` (HTTP and in-process dispatch)
- `src/node-host/runner.ts` (node proxy path)
- CLI wrappers that call `/act` (add `--timeout-ms` to `browser evaluate`)
3. Implement `src/browser/cdp-evaluate.ts`:
- connect to the browser-level CDP socket
- `Target.attachToTarget` to get a `sessionId`
- run `Runtime.evaluate` for page evaluate
- run `DOM.resolveNode` + `Runtime.callFunctionOn` for element evaluate
- on timeout/abort: best-effort `Runtime.terminateExecution` then close the socket
4. Extend stored role ref metadata to optionally include `backendDOMNodeId`:
- keep existing `{ role, name, nth }` behavior for Playwright actions
- add `backendDOMNodeId?: number` for CDP element targeting
5. Populate `backendDOMNodeId` during snapshot creation (best-effort):
- fetch AX tree via CDP (`Accessibility.getFullAXTree`)
- compute `(role, name, nth) -> backendDOMNodeId` and merge into the stored ref map
- if mapping is ambiguous or missing, leave the id undefined
6. Update `act:evaluate` routing:
- if no `ref`: always use CDP evaluate
- if `ref` resolves to a `backendDOMNodeId`: use CDP element evaluate
- otherwise: fall back to Playwright evaluate (still bounded and abortable)
7. Keep the existing "last resort" recovery path as a fallback, not the default path.
8. Add tests:
- stuck evaluate times out within budget and the next click/type succeeds
- abort cancels evaluate (client disconnect or timeout) and unblocks subsequent actions
- mapping failures cleanly fall back to Playwright
9. Add observability:
- evaluate duration and timeout counters
- terminateExecution usage
- fallback rate (CDP -> Playwright) and reasons
### Acceptance Criteria
- A deliberately hung `act:evaluate` returns within the caller budget and does not wedge the
tab for later actions.
- `timeoutMs` behaves consistently across CLI, agent tool, node proxy, and in-process calls.
- If `ref` can be mapped to `backendDOMNodeId`, element evaluate uses CDP; otherwise the
fallback path is still bounded and recoverable.
## Testing Plan
- Unit tests:
- `(role, name, nth)` matching logic between role refs and AX tree nodes.
- Budget helper behavior (headroom, remaining time math).
- Integration tests:
- CDP evaluate timeout returns within budget and does not block the next action.
- Abort cancels evaluate and triggers termination best-effort.
- Contract tests:
- Ensure `BrowserActRequest` and `BrowserActResponse` remain compatible.
## Risks And Mitigations
- Mapping is imperfect:
- Mitigation: best-effort mapping, fallback to Playwright evaluate, and add debug tooling.
- `Runtime.terminateExecution` has side effects:
- Mitigation: only use on timeout/abort and document the behavior in errors.
- Extra overhead:
- Mitigation: only fetch AX tree when snapshots are requested, cache per target, and keep
CDP session short lived.
- Extension relay limitations:
- Mitigation: use browser level attach APIs when per page sockets are not available, and
keep the current Playwright path as fallback.
## Open Questions
- Should the new engine be configurable as `playwright`, `cdp`, or `auto`?
- Do we want to expose a new "nodeRef" format for advanced users, or keep `ref` only?
- How should frame snapshots and selector scoped snapshots participate in AX mapping?

View File

@@ -160,6 +160,12 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
},
resetTriggers: ["/new", "/reset"],
store: "~/.openclaw/agents/default/sessions/sessions.json",
maintenance: {
mode: "warn",
pruneAfter: "30d",
maxEntries: 500,
rotateBytes: "10mb",
},
typingIntervalSeconds: 5,
sendPolicy: {
default: "allow",
@@ -344,6 +350,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
enabled: true,
store: "~/.openclaw/cron/cron.json",
maxConcurrentRuns: 2,
sessionRetention: "24h",
},
// Webhooks

View File

@@ -990,6 +990,10 @@ Controls how chat commands are enabled across connectors.
config: false, // allow /config (writes to disk)
debug: false, // allow /debug (runtime-only overrides)
restart: false, // allow /restart + gateway restart tool
allowFrom: {
"*": ["user1"], // optional per-provider command allowlist
discord: ["user:123"],
},
useAccessGroups: true, // enforce access-group allowlists/policies for commands
},
}
@@ -1008,9 +1012,14 @@ Notes:
- `channels.<provider>.configWrites` gates config mutations initiated by that channel (default: true). This applies to `/config set|unset` plus provider-specific auto-migrations (Telegram supergroup ID changes, Slack channel ID changes).
- `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
- Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
channel allowlists/pairing plus `commands.useAccessGroups`.
- `commands.allowFrom` sets a per-provider allowlist for command execution. When configured, it is the **only**
authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups` are ignored).
Use `"*"` for a global default; provider-specific keys (for example `discord`) override it.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies when `commands.allowFrom`
is not set.
- Slash commands and directives are only honored for **authorized senders**. If `commands.allowFrom` is set,
authorization comes solely from that list; otherwise it is derived from channel allowlists/pairing plus
`commands.useAccessGroups`.
### `web` (WhatsApp web channel runtime)
@@ -2767,6 +2776,12 @@ Controls session scoping, reset policy, reset triggers, and where the session st
// Default is already per-agent under ~/.openclaw/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating:
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
maintenance: {
mode: "warn",
pruneAfter: "30d",
maxEntries: 500,
rotateBytes: "10mb",
},
// Direct chats collapse to agent:<agentId>:<mainKey> (default: "main").
mainKey: "main",
agentToAgent: {
@@ -2803,6 +2818,11 @@ Fields:
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
- `maintenance`: session store maintenance settings for pruning, capping, and rotation.
- `mode`: `"warn"` (default) warns the active session (best-effort delivery) when it would be evicted without enforcing maintenance. `"enforce"` applies pruning and rotation.
- `pruneAfter`: remove entries older than this duration (for example `"30m"`, `"1h"`, `"30d"`). Default "30d".
- `maxEntries`: cap the number of session entries kept (default 500).
- `rotateBytes`: rotate `sessions.json` when it exceeds this size (for example `"10kb"`, `"1mb"`, `"10mb"`). Default "10mb".
### `skills` (skills config)
@@ -3156,12 +3176,17 @@ Defaults:
enabled: true,
token: "shared-secret",
path: "/hooks",
// Optional: restrict explicit `agentId` routing.
// Omit or include "*" to allow any agent.
// Set [] to deny all explicit `agentId` routing.
allowedAgentIds: ["hooks", "main"],
presets: ["gmail"],
transformsDir: "~/.openclaw/hooks",
mappings: [
{
match: { path: "gmail" },
action: "agent",
agentId: "hooks",
wakeMode: "now",
name: "Gmail",
sessionKey: "hook:gmail:{{messages[0].id}}",
@@ -3183,7 +3208,7 @@ Requests must include the hook token:
Endpoints:
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }`
- `POST /hooks/agent` → `{ message, name?, agentId?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }`
- `POST /hooks/<name>` → resolved via `hooks.mappings`
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
@@ -3194,6 +3219,8 @@ Mapping notes:
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module that returns a hook action.
- `agentId` can route to a specific agent; unknown IDs fall back to the default agent.
- `hooks.allowedAgentIds` restricts explicit `agentId` routing (`*` or omitted means allow all, `[]` denies all explicit routing).
- `deliver: true` sends the final reply to a channel; `channel` defaults to `last` (falls back to WhatsApp).
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Google Chat/Slack/Signal/iMessage/MS Teams).
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
@@ -3407,10 +3434,15 @@ Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron jobs
cron: {
enabled: true,
maxConcurrentRuns: 2,
sessionRetention: "24h",
},
}
```
Fields:
- `sessionRetention`: how long to keep completed cron run sessions before pruning. Accepts a duration string like `"24h"` or `"7d"`. Use `false` to disable pruning. Default is 24h.
---
_Next: [Agent Runtime](/concepts/agent)_ 🦞

View File

@@ -65,6 +65,24 @@ It writes config/workspace on the host:
Running on a VPS? See [Hetzner (Docker VPS)](/install/hetzner).
### Shell Helpers (optional)
For easier day-to-day Docker management, install `ClawDock`:
```bash
mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
```
**Add to your shell config (zsh):**
```bash
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrc
```
Then use `clawdock-start`, `clawdock-stop`, `clawdock-dashboard`, etc. Run `clawdock-help` for all commands.
See [`ClawDock` Helper README](https://github.com/openclaw/openclaw/blob/main/scripts/shell-helpers/README.md) for details.
### Manual flow (compose)
```bash

View File

@@ -40,6 +40,7 @@ See [Venice AI](/providers/venice).
- [Qwen (OAuth)](/providers/qwen)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Together AI](/providers/together)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)

View File

@@ -0,0 +1,65 @@
---
summary: "Together AI setup (auth + model selection)"
read_when:
- You want to use Together AI with OpenClaw
- You need the API key env var or CLI auth choice
---
# Together AI
The [Together AI](https://together.ai) provides access to leading open-source models including Llama, DeepSeek, Kimi, and more through a unified API.
- Provider: `together`
- Auth: `TOGETHER_API_KEY`
- API: OpenAI-compatible
## Quick start
1. Set the API key (recommended: store it for the Gateway):
```bash
openclaw onboard --auth-choice together-api-key
```
2. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "together/moonshotai/Kimi-K2.5" },
},
},
}
```
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice together-api-key \
--together-api-key "$TOGETHER_API_KEY"
```
This will set `together/moonshotai/Kimi-K2.5` as the default model.
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `TOGETHER_API_KEY`
is available to that process (for example, in `~/.clawdbot/.env` or via
`env.shellEnv`).
## Available models
Together AI provides access to many popular open-source models:
- **GLM 4.7 Fp8** - Default model with 200K context window
- **Llama 3.3 70B Instruct Turbo** - Fast, efficient instruction following
- **Llama 4 Scout** - Vision model with image understanding
- **Llama 4 Maverick** - Advanced vision and reasoning
- **DeepSeek V3.1** - Powerful coding and reasoning model
- **DeepSeek R1** - Advanced reasoning model
- **Kimi K2 Instruct** - High-performance model with 262K context window
All models support standard chat completions and are OpenAI API compatible.

View File

@@ -1,4 +1,5 @@
---
title: "Default AGENTS.md"
summary: "Default OpenClaw agent instructions and skills roster for the personal assistant setup"
read_when:
- Starting a new OpenClaw agent session

View File

@@ -1,4 +1,5 @@
---
title: "Release Checklist"
summary: "Step-by-step release checklist for npm + macOS app"
read_when:
- Cutting a new npm release

View File

@@ -1,4 +1,5 @@
---
title: "AGENTS.md Template"
summary: "Workspace template for AGENTS.md"
read_when:
- Bootstrapping a workspace manually

View File

@@ -1,4 +1,5 @@
---
title: "BOOT.md Template"
summary: "Workspace template for BOOT.md"
read_when:
- Adding a BOOT.md checklist

View File

@@ -1,4 +1,5 @@
---
title: "BOOTSTRAP.md Template"
summary: "First-run ritual for new agents"
read_when:
- Bootstrapping a workspace manually

View File

@@ -1,4 +1,5 @@
---
title: "HEARTBEAT.md Template"
summary: "Workspace template for HEARTBEAT.md"
read_when:
- Bootstrapping a workspace manually

View File

@@ -1,4 +1,5 @@
---
title: "SOUL.md Template"
summary: "Workspace template for SOUL.md"
read_when:
- Bootstrapping a workspace manually

View File

@@ -1,4 +1,5 @@
---
title: "TOOLS.md Template"
summary: "Workspace template for TOOLS.md"
read_when:
- Bootstrapping a workspace manually

View File

@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000).
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@@ -0,0 +1,51 @@
---
summary: "Overview of OpenClaw onboarding options and flows"
read_when:
- Choosing an onboarding path
- Setting up a new environment
title: "Onboarding Overview"
sidebarTitle: "Onboarding Overview"
---
# Onboarding Overview
OpenClaw supports multiple onboarding paths depending on where the Gateway runs
and how you prefer to configure providers.
## Choose your onboarding path
- **CLI wizard** for macOS, Linux, and Windows (via WSL2).
- **macOS app** for a guided first run on Apple silicon or Intel Macs.
## CLI onboarding wizard
Run the wizard in a terminal:
```bash
openclaw onboard
```
Use the CLI wizard when you want full control of the Gateway, workspace,
channels, and skills. Docs:
- [Onboarding Wizard (CLI)](/start/wizard)
- [`openclaw onboard` command](/cli/onboard)
## macOS app onboarding
Use the OpenClaw app when you want a fully guided setup on macOS. Docs:
- [Onboarding (macOS App)](/start/onboarding)
## Custom Provider
If you need an endpoint that is not listed, including hosted providers that
expose standard OpenAI or Anthropic APIs, choose **Custom Provider** in the
CLI wizard. You will be asked to:
- Pick OpenAI-compatible, Anthropic-compatible, or **Unknown** (auto-detect).
- Enter a base URL and API key (if required by the provider).
- Provide a model ID and optional alias.
- Choose an Endpoint ID so multiple custom endpoints can coexist.
For detailed steps, follow the CLI onboarding docs above.

View File

@@ -12,6 +12,7 @@ sidebarTitle: "Onboarding: macOS App"
This doc describes the **current** first‑run onboarding flow. The goal is a
smooth “day 0” experience: pick where the Gateway runs, connect auth, run the
wizard, and let the agent bootstrap itself.
For a general overview of onboarding paths, see [Onboarding Overview](/start/onboarding-overview).
<Steps>
<Step title="Approve macOS warning">

View File

@@ -62,7 +62,8 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
**Local mode (default)** walks you through these steps:
1. **Model/Auth** — Anthropic API key (recommended), OAuth, OpenAI, or other providers. Pick a default model.
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
@@ -104,5 +105,6 @@ RPC API, and a full list of config fields the wizard writes, see the
## Related docs
- CLI command reference: [`openclaw onboard`](/cli/onboard)
- Onboarding overview: [Onboarding Overview](/start/onboarding-overview)
- macOS app onboarding: [Onboarding](/start/onboarding)
- Agent first-run ritual: [Agent Bootstrapping](/start/bootstrapping)

View File

@@ -18,7 +18,8 @@ There are two related systems:
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
- Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
- Directives are only applied for **authorized senders**. If `commands.allowFrom` is set, it is the only
allowlist used; otherwise authorization comes from channel allowlists/pairing plus `commands.useAccessGroups`.
Unauthorized senders see directives treated as plain text.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
@@ -37,6 +38,10 @@ They run immediately, are stripped before the model sees the message, and the re
config: false,
debug: false,
restart: false,
allowFrom: {
"*": ["user1"],
discord: ["user:123"],
},
useAccessGroups: true,
},
}
@@ -55,7 +60,10 @@ They run immediately, are stripped before the model sees the message, and the re
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
- `commands.config` (default `false`) enables `/config` (reads/writes `openclaw.json`).
- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides).
- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands.
- `commands.allowFrom` (optional) sets a per-provider allowlist for command authorization. When configured, it is the
only authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups`
are ignored). Use `"*"` for a global default; provider-specific keys override it.
- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands when `commands.allowFrom` is not set.
## Command list

View File

@@ -19,8 +19,8 @@ x-i18n:
## 选择提供商
- **Railway**(一键 + 浏览器设置):[Railway](/railway)
- **Northflank**(一键 + 浏览器设置):[Northflank](/northflank)
- **Railway**(一键 + 浏览器设置):[Railway](/install/railway)
- **Northflank**(一键 + 浏览器设置):[Northflank](/install/northflank)
- **Oracle Cloud(永久免费)**:[Oracle](/platforms/oracle) — $0/月(永久免费,ARM;容量/注册可能不太稳定)
- **Fly.io**:[Fly.io](/install/fly)
- **Hetzner(Docker)**:[Hetzner](/install/hetzner)

View File

@@ -83,6 +83,7 @@ vi.mock("@opentelemetry/sdk-trace-base", () => ({
}));
vi.mock("@opentelemetry/resources", () => ({
resourceFromAttributes: vi.fn((attrs: Record<string, unknown>) => attrs),
Resource: class {
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(_value?: unknown) {}

View File

@@ -7,9 +7,11 @@ import {
type HistoryEntry,
} from "openclaw/plugin-sdk";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
import {
resolveFeishuGroupConfig,
@@ -21,6 +23,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
// --- Message deduplication ---
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
const DEDUP_MAX_SIZE = 1_000;
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
let lastCleanupTime = Date.now();
function tryRecordMessage(messageId: string): boolean {
const now = Date.now();
// Throttled cleanup: evict expired entries at most once per interval
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
for (const [id, ts] of processedMessageIds) {
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
}
lastCleanupTime = now;
}
if (processedMessageIds.has(messageId)) return false;
// Evict oldest entries if cache is full
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
const first = processedMessageIds.keys().next().value!;
processedMessageIds.delete(first);
}
processedMessageIds.set(messageId, now);
return true;
}
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
@@ -30,16 +63,12 @@ type PermissionError = {
};
function extractPermissionError(err: unknown): PermissionError | null {
if (!err || typeof err !== "object") {
return null;
}
if (!err || typeof err !== "object") return null;
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") {
return null;
}
if (!data || typeof data !== "object") return null;
const feishuErr = data as {
code?: number;
@@ -48,9 +77,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) {
return null;
}
if (feishuErr.code !== 99991672) return null;
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
@@ -82,28 +109,20 @@ type SenderNameResult = {
async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderOpenId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderOpenId, log } = params;
if (!account.configured) {
return {};
}
if (!senderOpenId) {
return {};
}
if (!account.configured) return {};
if (!senderOpenId) return {};
const cached = senderNameCache.get(senderOpenId);
const now = Date.now();
if (cached && cached.expireAt > now) {
return { name: cached.name };
}
if (cached && cached.expireAt > now) return { name: cached.name };
try {
const client = createFeishuClient(account);
// contact/v3/users/:user_id?user_id_type=open_id
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
const res: any = await client.contact.user.get({
path: { user_id: senderOpenId },
params: { user_id_type: "open_id" },
@@ -196,12 +215,8 @@ function parseMessageContent(content: string, messageType: string): string {
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
const mentions = event.message.mentions ?? [];
if (mentions.length === 0) {
return false;
}
if (!botOpenId) {
return mentions.length > 0;
}
if (mentions.length === 0) return false;
if (!botOpenId) return mentions.length > 0;
return mentions.some((m) => m.id.open_id === botOpenId);
}
@@ -209,9 +224,7 @@ function stripBotMention(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
): string {
if (!mentions || mentions.length === 0) {
return text;
}
if (!mentions || mentions.length === 0) return text;
let result = text;
for (const mention of mentions) {
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
@@ -523,6 +536,13 @@ export async function handleFeishuMessage(params: {
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Dedup check: skip if this message was already processed
const messageId = event.message.message_id;
if (!tryRecordMessage(messageId)) {
log(`feishu: skipping duplicate message ${messageId}`);
return;
}
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
@@ -532,9 +552,7 @@ export async function handleFeishuMessage(params: {
senderOpenId: ctx.senderOpenId,
log,
});
if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
}
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
// Track permission error to inform agent later (with cooldown to avoid repetition)
let permissionErrorForAgent: PermissionError | undefined;
@@ -647,16 +665,61 @@ export async function handleFeishuMessage(params: {
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const route = core.channel.routing.resolveAgentRoute({
// Resolve peer ID for session routing
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
// get a separate session from the main group chat.
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
if (isGroup && ctx.rootId) {
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
const topicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
if (topicSessionMode === "enabled") {
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
}
}
let route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: isGroup ? ctx.chatId : ctx.senderOpenId,
id: peerId,
},
});
// Dynamic agent creation for DM users
// When enabled, creates a unique agent instance with its own workspace for each DM user.
let effectiveCfg = cfg;
if (!isGroup && route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const runtime = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
senderOpenId: ctx.senderOpenId,
dynamicCfg,
log: (msg) => log(msg),
});
if (result.created) {
effectiveCfg = result.updatedCfg;
// Re-resolve route with updated config
route = core.channel.routing.resolveAgentRoute({
cfg: result.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: { kind: "direct", id: ctx.senderOpenId },
});
log(
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
);
}
}
}
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
@@ -736,6 +799,7 @@ export async function handleFeishuMessage(params: {
const permissionCtx = core.channel.reply.finalizeInboundContext({
Body: permissionBody,
BodyForAgent: permissionNotifyBody,
RawBody: permissionNotifyBody,
CommandBody: permissionNotifyBody,
From: feishuFrom,
@@ -810,8 +874,19 @@ export async function handleFeishuMessage(params: {
});
}
const inboundHistory =
isGroup && historyKey && historyLimit > 0 && chatHistories
? (chatHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: ctx.content,
InboundHistory: inboundHistory,
RawBody: ctx.content,
CommandBody: ctx.content,
From: feishuFrom,
@@ -825,6 +900,7 @@ export async function handleFeishuMessage(params: {
Provider: "feishu" as const,
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: true,

View File

@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
const meta: ChannelMeta = {
id: "feishu",
@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
},
capabilities: {
chatTypes: ["direct", "group"],
chatTypes: ["direct", "channel"],
polls: false,
threads: true,
media: true,
reactions: true,
threads: false,
polls: false,
nativeCommands: true,
blockStreaming: true,
edit: true,
reply: true,
},
agentPrompt: {
messageToolHints: () => [
@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const _account = resolveFeishuAccount({ cfg, accountId });
const account = resolveFeishuAccount({ cfg, accountId });
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
)?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") {
return [];
}
if (groupPolicy !== "open") return [];
return [
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];

View File

@@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
.strict()
.optional();
/**
* Dynamic agent creation configuration.
* When enabled, a new agent is created for each unique DM user.
*/
const DynamicAgentCreationSchema = z
.object({
enabled: z.boolean().optional(),
workspaceTemplate: z.string().optional(),
agentDirTemplate: z.string().optional(),
maxAgents: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Feishu tools configuration.
* Controls which tool categories are enabled.
@@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
.strict()
.optional();
/**
* Topic session isolation mode for group chats.
* - "disabled" (default): All messages in a group share one session
* - "enabled": Messages in different topics get separate sessions
*
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
* for messages within a topic thread, allowing isolated conversations.
*/
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
export const FeishuGroupSchema = z
.object({
requireMention: z.boolean().optional(),
@@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topicSessionMode: TopicSessionModeSchema,
})
.strict();
@@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional().default(true),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
topicSessionMode: TopicSessionModeSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
@@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
tools: FeishuToolsConfigSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,
// Multi-account configuration
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
})

View File

@@ -0,0 +1,131 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { DynamicAgentCreationConfig } from "./types.js";
export type MaybeCreateDynamicAgentResult = {
created: boolean;
updatedCfg: OpenClawConfig;
agentId?: string;
};
/**
* Check if a dynamic agent should be created for a DM user and create it if needed.
* This creates a unique agent instance with its own workspace for each DM user.
*/
export async function maybeCreateDynamicAgent(params: {
cfg: OpenClawConfig;
runtime: PluginRuntime;
senderOpenId: string;
dynamicCfg: DynamicAgentCreationConfig;
log: (msg: string) => void;
}): Promise<MaybeCreateDynamicAgentResult> {
const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
// Check if there's already a binding for this user
const existingBindings = cfg.bindings ?? [];
const hasBinding = existingBindings.some(
(b) =>
b.match?.channel === "feishu" &&
b.match?.peer?.kind === "direct" &&
b.match?.peer?.id === senderOpenId,
);
if (hasBinding) {
return { created: false, updatedCfg: cfg };
}
// Check maxAgents limit if configured
if (dynamicCfg.maxAgents !== undefined) {
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
a.id.startsWith("feishu-"),
).length;
if (feishuAgentCount >= dynamicCfg.maxAgents) {
log(
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
return { created: false, updatedCfg: cfg };
}
}
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
const agentId = `feishu-${senderOpenId}`;
// Check if agent already exists (but binding was missing)
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
if (existingAgent) {
// Agent exists but binding doesn't - just add the binding
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
const updatedCfg: OpenClawConfig = {
...cfg,
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
// Resolve path templates with substitutions
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
const workspace = resolveUserPath(
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
const agentDir = resolveUserPath(
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
log(` workspace: ${workspace}`);
log(` agentDir: ${agentDir}`);
// Create directories
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
// Update configuration with new agent and binding
const updatedCfg: OpenClawConfig = {
...cfg,
agents: {
...cfg.agents,
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
},
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
// Write updated config using PluginRuntime API
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
/**
* Resolve a path that may start with ~ to the user's home directory.
*/
function resolveUserPath(p: string): string {
if (p.startsWith("~/")) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}

View File

@@ -1,5 +1,6 @@
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
import * as Lark from "@larksuiteoapi/node-sdk";
import * as http from "http";
import type { ResolvedFeishuAccount } from "./types.js";
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
@@ -13,8 +14,9 @@ export type MonitorFeishuOpts = {
accountId?: string;
};
// Per-account WebSocket clients and bot info
// Per-account WebSocket clients, HTTP servers, and bot info
const wsClients = new Map<string, Lark.WSClient>();
const httpServers = new Map<string, http.Server>();
const botOpenIds = new Map<string, string>();
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
@@ -27,44 +29,29 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string |
}
/**
* Monitor a single Feishu account.
* Register common event handlers on an EventDispatcher.
* When fireAndForget is true (webhook mode), message handling is not awaited
* to avoid blocking the HTTP response (Lark requires <3s response).
*/
async function monitorSingleAccount(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
}): Promise<void> {
const { cfg, account, runtime, abortSignal } = params;
const { accountId } = account;
function registerEventHandlers(
eventDispatcher: Lark.EventDispatcher,
context: {
cfg: ClawdbotConfig;
accountId: string;
runtime?: RuntimeEnv;
chatHistories: Map<string, HistoryEntry[]>;
fireAndForget?: boolean;
},
) {
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Fetch bot open_id
const botOpenId = await fetchBotOpenId(account);
botOpenIds.set(accountId, botOpenId ?? "");
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
const connectionMode = account.config.connectionMode ?? "websocket";
if (connectionMode !== "websocket") {
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
return;
}
log(`feishu[${accountId}]: starting WebSocket connection...`);
const wsClient = createFeishuWSClient(account);
wsClients.set(accountId, wsClient);
const chatHistories = new Map<string, HistoryEntry[]>();
const eventDispatcher = createEventDispatcher(account);
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
try {
const event = data as unknown as FeishuMessageEvent;
await handleFeishuMessage({
const promise = handleFeishuMessage({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
@@ -72,6 +59,13 @@ async function monitorSingleAccount(params: {
chatHistories,
accountId,
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
});
} else {
await promise;
}
} catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
@@ -96,6 +90,66 @@ async function monitorSingleAccount(params: {
}
},
});
}
type MonitorAccountParams = {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
};
/**
* Monitor a single Feishu account.
*/
async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
const { cfg, account, runtime, abortSignal } = params;
const { accountId } = account;
const log = runtime?.log ?? console.log;
// Fetch bot open_id
const botOpenId = await fetchBotOpenId(account);
botOpenIds.set(accountId, botOpenId ?? "");
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
const connectionMode = account.config.connectionMode ?? "websocket";
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();
registerEventHandlers(eventDispatcher, {
cfg,
accountId,
runtime,
chatHistories,
fireAndForget: connectionMode === "webhook",
});
if (connectionMode === "webhook") {
return monitorWebhook({ params, accountId, eventDispatcher });
}
return monitorWebSocket({ params, accountId, eventDispatcher });
}
type ConnectionParams = {
params: MonitorAccountParams;
accountId: string;
eventDispatcher: Lark.EventDispatcher;
};
async function monitorWebSocket({
params,
accountId,
eventDispatcher,
}: ConnectionParams): Promise<void> {
const { account, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
log(`feishu[${accountId}]: starting WebSocket connection...`);
const wsClient = createFeishuWSClient(account);
wsClients.set(accountId, wsClient);
return new Promise((resolve, reject) => {
const cleanup = () => {
@@ -118,7 +172,7 @@ async function monitorSingleAccount(params: {
abortSignal?.addEventListener("abort", handleAbort, { once: true });
try {
void wsClient.start({ eventDispatcher });
wsClient.start({ eventDispatcher });
log(`feishu[${accountId}]: WebSocket client started`);
} catch (err) {
cleanup();
@@ -128,6 +182,57 @@ async function monitorSingleAccount(params: {
});
}
async function monitorWebhook({
params,
accountId,
eventDispatcher,
}: ConnectionParams): Promise<void> {
const { account, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const port = account.config.webhookPort ?? 3000;
const path = account.config.webhookPath ?? "/feishu/events";
log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
const server = http.createServer();
server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
httpServers.set(accountId, server);
return new Promise((resolve, reject) => {
const cleanup = () => {
server.close();
httpServers.delete(accountId);
botOpenIds.delete(accountId);
};
const handleAbort = () => {
log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
cleanup();
resolve();
};
if (abortSignal?.aborted) {
cleanup();
resolve();
return;
}
abortSignal?.addEventListener("abort", handleAbort, { once: true });
server.listen(port, () => {
log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
});
server.on("error", (err) => {
error(`feishu[${accountId}]: Webhook server error: ${err}`);
abortSignal?.removeEventListener("abort", handleAbort);
reject(err);
});
});
}
/**
* Main entry: start monitoring for all enabled accounts.
*/
@@ -182,9 +287,18 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
export function stopFeishuMonitor(accountId?: string): void {
if (accountId) {
wsClients.delete(accountId);
const server = httpServers.get(accountId);
if (server) {
server.close();
httpServers.delete(accountId);
}
botOpenIds.delete(accountId);
} else {
wsClients.clear();
for (const server of httpServers.values()) {
server.close();
}
httpServers.clear();
botOpenIds.clear();
}
}

View File

@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import type { MentionTarget } from "./mention.js";
import type { FeishuSendResult } from "./types.js";
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
@@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
/**
* Build a Feishu interactive card with markdown content.
* Cards render markdown properly (code blocks, tables, links, etc.)
* Uses schema 2.0 format for proper markdown rendering.
*/
export function buildMarkdownCard(text: string): Record<string, unknown> {
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
elements: [
{
tag: "markdown",
content: text,
},
],
body: {
elements: [
{
tag: "markdown",
content: text,
},
],
},
};
}

View File

@@ -73,3 +73,10 @@ export type FeishuToolsConfig = {
perm?: boolean;
scopes?: boolean;
};
export type DynamicAgentCreationConfig = {
enabled?: boolean;
workspaceTemplate?: string;
agentDirTemplate?: string;
maxAgents?: number;
};

View File

@@ -1,8 +1,8 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import {
emptyPluginConfigSchema,
isWSL2Sync,
type OpenClawPluginApi,
type ProviderAuthContext,
} from "openclaw/plugin-sdk";
@@ -52,32 +52,8 @@ function generatePkce(): { verifier: string; challenge: string } {
return { verifier, challenge };
}
function isWSL(): boolean {
if (process.platform !== "linux") {
return false;
}
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) {
return false;
}
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
return isRemote || isWSL2Sync();
}
function buildAuthUrl(params: { challenge: string; state: string }): string {

View File

@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { createServer } from "node:http";
import { delimiter, dirname, join } from "node:path";
import { isWSL2Sync } from "openclaw/plugin-sdk";
const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
@@ -177,32 +178,8 @@ function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string }
);
}
function isWSL(): boolean {
if (process.platform !== "linux") {
return false;
}
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) {
return false;
}
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
return isRemote || isWSL2Sync();
}
function generatePkce(): { verifier: string; challenge: string } {

View File

@@ -655,6 +655,7 @@ async function processMessageWithPipeline(params: {
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
RawBody: rawBody,
CommandBody: rawBody,
From: `googlechat:${senderId}`,

17
extensions/irc/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { ircPlugin } from "./src/channel.js";
import { setIrcRuntime } from "./src/runtime.js";
const plugin = {
id: "irc",
name: "IRC",
description: "IRC channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setIrcRuntime(api.runtime);
api.registerChannel({ plugin: ircPlugin as ChannelPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "irc",
"channels": ["irc"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@openclaw/irc",
"version": "2026.2.9",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,268 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
export type ResolvedIrcAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
host: string;
port: number;
tls: boolean;
nick: string;
username: string;
realname: string;
password: string;
passwordSource: "env" | "passwordFile" | "config" | "none";
config: IrcAccountConfig;
};
function parseTruthy(value?: string): boolean {
if (!value) {
return false;
}
return TRUTHY_ENV.has(value.trim().toLowerCase());
}
function parseIntEnv(value?: string): number | undefined {
if (!value?.trim()) {
return undefined;
}
const parsed = Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) {
return undefined;
}
return parsed;
}
function parseListEnv(value?: string): string[] | undefined {
if (!value?.trim()) {
return undefined;
}
const parsed = value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
return parsed.length > 0 ? parsed : undefined;
}
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.irc?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (key.trim()) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig | undefined {
const accounts = cfg.channels?.irc?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as IrcAccountConfig | undefined;
if (direct) {
return direct;
}
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
return matchKey ? (accounts[matchKey] as IrcAccountConfig | undefined) : undefined;
}
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.irc ?? {}) as IrcAccountConfig & {
accounts?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
const merged: IrcAccountConfig = { ...base, ...account };
if (base.nickserv || account.nickserv) {
merged.nickserv = {
...base.nickserv,
...account.nickserv,
};
}
return merged;
}
function resolvePassword(accountId: string, merged: IrcAccountConfig) {
if (accountId === DEFAULT_ACCOUNT_ID) {
const envPassword = process.env.IRC_PASSWORD?.trim();
if (envPassword) {
return { password: envPassword, source: "env" as const };
}
}
if (merged.passwordFile?.trim()) {
try {
const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim();
if (filePassword) {
return { password: filePassword, source: "passwordFile" as const };
}
} catch {
// Ignore unreadable files here; status will still surface missing configuration.
}
}
const configPassword = merged.password?.trim();
if (configPassword) {
return { password: configPassword, source: "config" as const };
}
return { password: "", source: "none" as const };
}
function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig): IrcNickServConfig {
const base = nickserv ?? {};
const envPassword =
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_PASSWORD?.trim() : undefined;
const envRegisterEmail =
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_REGISTER_EMAIL?.trim() : undefined;
const passwordFile = base.passwordFile?.trim();
let resolvedPassword = base.password?.trim() || envPassword || "";
if (!resolvedPassword && passwordFile) {
try {
resolvedPassword = readFileSync(passwordFile, "utf-8").trim();
} catch {
// Ignore unreadable files; monitor/probe status will surface failures.
}
}
const merged: IrcNickServConfig = {
...base,
service: base.service?.trim() || undefined,
passwordFile: passwordFile || undefined,
password: resolvedPassword || undefined,
registerEmail: base.registerEmail?.trim() || envRegisterEmail || undefined,
};
return merged;
}
export function listIrcAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultIrcAccountId(cfg: CoreConfig): string {
const ids = listIrcAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveIrcAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedIrcAccount {
const hasExplicitAccountId = Boolean(params.accountId?.trim());
const baseEnabled = params.cfg.channels?.irc?.enabled !== false;
const resolve = (accountId: string) => {
const merged = mergeIrcAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tls =
typeof merged.tls === "boolean"
? merged.tls
: accountId === DEFAULT_ACCOUNT_ID && process.env.IRC_TLS
? parseTruthy(process.env.IRC_TLS)
: true;
const envPort =
accountId === DEFAULT_ACCOUNT_ID ? parseIntEnv(process.env.IRC_PORT) : undefined;
const port = merged.port ?? envPort ?? (tls ? 6697 : 6667);
const envChannels =
accountId === DEFAULT_ACCOUNT_ID ? parseListEnv(process.env.IRC_CHANNELS) : undefined;
const host = (
merged.host?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_HOST?.trim() : "") ||
""
).trim();
const nick = (
merged.nick?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICK?.trim() : "") ||
""
).trim();
const username = (
merged.username?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_USERNAME?.trim() : "") ||
nick ||
"openclaw"
).trim();
const realname = (
merged.realname?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_REALNAME?.trim() : "") ||
"OpenClaw"
).trim();
const passwordResolution = resolvePassword(accountId, merged);
const nickserv = resolveNickServConfig(accountId, merged.nickserv);
const config: IrcAccountConfig = {
...merged,
channels: merged.channels ?? envChannels,
tls,
port,
host,
nick,
username,
realname,
nickserv,
};
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
configured: Boolean(host && nick),
host,
port,
tls,
nick,
username,
realname,
password: passwordResolution.password,
passwordSource: passwordResolution.source,
config,
} satisfies ResolvedIrcAccount;
};
const normalized = normalizeAccountId(params.accountId);
const primary = resolve(normalized);
if (hasExplicitAccountId) {
return primary;
}
if (primary.configured) {
return primary;
}
const fallbackId = resolveDefaultIrcAccountId(params.cfg);
if (fallbackId === primary.accountId) {
return primary;
}
const fallback = resolve(fallbackId);
if (!fallback.configured) {
return primary;
}
return fallback;
}
export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] {
return listIrcAccountIds(cfg)
.map((accountId) => resolveIrcAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,367 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
formatPairingApproveHint,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
deleteAccountFromConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import type { CoreConfig, IrcProbe } from "./types.js";
import {
listIrcAccountIds,
resolveDefaultIrcAccountId,
resolveIrcAccount,
type ResolvedIrcAccount,
} from "./accounts.js";
import { IrcConfigSchema } from "./config-schema.js";
import { monitorIrcProvider } from "./monitor.js";
import {
normalizeIrcMessagingTarget,
looksLikeIrcTargetId,
isChannelTarget,
normalizeIrcAllowEntry,
} from "./normalize.js";
import { ircOnboardingAdapter } from "./onboarding.js";
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
import { probeIrc } from "./probe.js";
import { getIrcRuntime } from "./runtime.js";
import { sendMessageIrc } from "./send.js";
const meta = getChatChannelMeta("irc");
function normalizePairingTarget(raw: string): string {
const normalized = normalizeIrcAllowEntry(raw);
if (!normalized) {
return "";
}
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
}
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
id: "irc",
meta: {
...meta,
quickstartAllowFrom: true,
},
onboarding: ircOnboardingAdapter,
pairing: {
idLabel: "ircUser",
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
notifyApproval: async ({ id }) => {
const target = normalizePairingTarget(id);
if (!target) {
throw new Error(`invalid IRC pairing id: ${id}`);
}
await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.irc"] },
configSchema: buildChannelConfigSchema(IrcConfigSchema),
config: {
listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "irc",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "irc",
accountId,
clearBaseFields: [
"name",
"host",
"port",
"tls",
"nick",
"username",
"realname",
"password",
"passwordFile",
"channels",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
passwordSource: account.passwordSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.irc?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.irc.accounts.${resolvedAccountId}.`
: "channels.irc.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: `${basePath}allowFrom`,
approveHint: formatPairingApproveHint("irc"),
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy === "open") {
warnings.push(
'- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.',
);
}
if (!account.config.tls) {
warnings.push(
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
);
}
if (account.config.nickserv?.register) {
warnings.push(
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
);
if (!account.config.nickserv.password?.trim()) {
warnings.push(
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
);
}
}
return warnings;
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
if (!groupId) {
return true;
}
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
return resolveIrcRequireMention({
groupConfig: match.groupConfig,
wildcardConfig: match.wildcardConfig,
});
},
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
if (!groupId) {
return undefined;
}
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
},
},
messaging: {
normalizeTarget: normalizeIrcMessagingTarget,
targetResolver: {
looksLikeId: looksLikeIrcTargetId,
hint: "<#channel|nick>",
},
},
resolver: {
resolveTargets: async ({ inputs, kind }) => {
return inputs.map((input) => {
const normalized = normalizeIrcMessagingTarget(input);
if (!normalized) {
return {
input,
resolved: false,
note: "invalid IRC target",
};
}
if (kind === "group") {
const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`;
return {
input,
resolved: true,
id: groupId,
name: groupId,
};
}
if (isChannelTarget(normalized)) {
return {
input,
resolved: false,
note: "expected user target",
};
}
return {
input,
resolved: true,
id: normalized,
name: normalized,
};
});
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() ?? "";
const ids = new Set<string>();
for (const entry of account.config.allowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
for (const entry of account.config.groupAllowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
for (const group of Object.values(account.config.groups ?? {})) {
for (const entry of group.allowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
}
return Array.from(ids)
.filter((id) => (q ? id.includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }));
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() ?? "";
const groupIds = new Set<string>();
for (const channel of account.config.channels ?? []) {
const normalized = normalizeIrcMessagingTarget(channel);
if (normalized && isChannelTarget(normalized)) {
groupIds.add(normalized);
}
}
for (const group of Object.keys(account.config.groups ?? {})) {
if (group === "*") {
continue;
}
const normalized = normalizeIrcMessagingTarget(group);
if (normalized && isChannelTarget(normalized)) {
groupIds.add(normalized);
}
}
return Array.from(groupIds)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id, name: id }));
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 350,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageIrc(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "irc", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
const result = await sendMessageIrc(to, combined, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "irc", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ account, snapshot }) => ({
configured: snapshot.configured ?? false,
host: account.host,
port: snapshot.port,
tls: account.tls,
nick: account.nick,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ cfg, account, timeoutMs }) =>
probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
passwordSource: account.passwordSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
ctx.log?.info(
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
);
const { stop } = await monitorIrcProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
return { stop };
},
},
};

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { buildIrcNickServCommands } from "./client.js";
describe("irc client nickserv", () => {
it("builds IDENTIFY command when password is set", () => {
expect(
buildIrcNickServCommands({
password: "secret",
}),
).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]);
});
it("builds REGISTER command when enabled with email", () => {
expect(
buildIrcNickServCommands({
password: "secret",
register: true,
registerEmail: "bot@example.com",
}),
).toEqual([
"PRIVMSG NickServ :IDENTIFY secret",
"PRIVMSG NickServ :REGISTER secret bot@example.com",
]);
});
it("rejects register without registerEmail", () => {
expect(() =>
buildIrcNickServCommands({
password: "secret",
register: true,
}),
).toThrow(/registerEmail/);
});
it("sanitizes outbound NickServ payloads", () => {
expect(
buildIrcNickServCommands({
service: "NickServ\n",
password: "secret\r\nJOIN #bad",
}),
).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]);
});
});

View File

@@ -0,0 +1,439 @@
import net from "node:net";
import tls from "node:tls";
import {
parseIrcLine,
parseIrcPrefix,
sanitizeIrcOutboundText,
sanitizeIrcTarget,
} from "./protocol.js";
const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
export type IrcPrivmsgEvent = {
senderNick: string;
senderUser?: string;
senderHost?: string;
target: string;
text: string;
rawLine: string;
};
export type IrcClientOptions = {
host: string;
port: number;
tls: boolean;
nick: string;
username: string;
realname: string;
password?: string;
nickserv?: IrcNickServOptions;
channels?: string[];
connectTimeoutMs?: number;
messageChunkMaxChars?: number;
abortSignal?: AbortSignal;
onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>;
onNotice?: (text: string, target?: string) => void;
onError?: (error: Error) => void;
onLine?: (line: string) => void;
};
export type IrcNickServOptions = {
enabled?: boolean;
service?: string;
password?: string;
register?: boolean;
registerEmail?: string;
};
export type IrcClient = {
nick: string;
isReady: () => boolean;
sendRaw: (line: string) => void;
join: (channel: string) => void;
sendPrivmsg: (target: string, text: string) => void;
quit: (reason?: string) => void;
close: () => void;
};
function toError(err: unknown): Error {
if (err instanceof Error) {
return err;
}
return new Error(typeof err === "string" ? err : JSON.stringify(err));
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
timeoutMs,
);
promise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
function buildFallbackNick(nick: string): string {
const normalized = nick.replace(/\s+/g, "");
const safe = normalized.replace(/[^A-Za-z0-9_\-\[\]\\`^{}|]/g, "");
const base = safe || "openclaw";
const suffix = "_";
const maxNickLen = 30;
if (base.length >= maxNickLen) {
return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
}
return `${base}${suffix}`;
}
export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
if (!options || options.enabled === false) {
return [];
}
const password = sanitizeIrcOutboundText(options.password ?? "");
if (!password) {
return [];
}
const service = sanitizeIrcTarget(options.service?.trim() || "NickServ");
const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`];
if (options.register) {
const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? "");
if (!registerEmail) {
throw new Error("IRC NickServ register requires registerEmail");
}
commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`);
}
return commands;
}
export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
const messageChunkMaxChars =
options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350;
if (!options.host.trim()) {
throw new Error("IRC host is required");
}
if (!options.nick.trim()) {
throw new Error("IRC nick is required");
}
const desiredNick = options.nick.trim();
let currentNick = desiredNick;
let ready = false;
let closed = false;
let nickServRecoverAttempted = false;
let fallbackNickAttempted = false;
const socket = options.tls
? tls.connect({
host: options.host,
port: options.port,
servername: options.host,
})
: net.connect({ host: options.host, port: options.port });
socket.setEncoding("utf8");
let resolveReady: (() => void) | null = null;
let rejectReady: ((error: Error) => void) | null = null;
const readyPromise = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const fail = (err: unknown) => {
const error = toError(err);
if (options.onError) {
options.onError(error);
}
if (!ready && rejectReady) {
rejectReady(error);
rejectReady = null;
resolveReady = null;
}
};
const sendRaw = (line: string) => {
const cleaned = line.replace(/[\r\n]+/g, "").trim();
if (!cleaned) {
throw new Error("IRC command cannot be empty");
}
socket.write(`${cleaned}\r\n`);
};
const tryRecoverNickCollision = (): boolean => {
const nickServEnabled = options.nickserv?.enabled !== false;
const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? "");
if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) {
nickServRecoverAttempted = true;
try {
const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ");
sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`);
sendRaw(`NICK ${desiredNick}`);
return true;
} catch (err) {
fail(err);
}
}
if (!fallbackNickAttempted) {
fallbackNickAttempted = true;
const fallbackNick = buildFallbackNick(desiredNick);
if (fallbackNick.toLowerCase() !== currentNick.toLowerCase()) {
try {
sendRaw(`NICK ${fallbackNick}`);
currentNick = fallbackNick;
return true;
} catch (err) {
fail(err);
}
}
}
return false;
};
const join = (channel: string) => {
const target = sanitizeIrcTarget(channel);
if (!target.startsWith("#") && !target.startsWith("&")) {
throw new Error(`IRC JOIN target must be a channel: ${channel}`);
}
sendRaw(`JOIN ${target}`);
};
const sendPrivmsg = (target: string, text: string) => {
const normalizedTarget = sanitizeIrcTarget(target);
const cleaned = sanitizeIrcOutboundText(text);
if (!cleaned) {
return;
}
let remaining = cleaned;
while (remaining.length > 0) {
let chunk = remaining;
if (chunk.length > messageChunkMaxChars) {
let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars);
if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
splitAt = messageChunkMaxChars;
}
chunk = chunk.slice(0, splitAt).trim();
}
if (!chunk) {
break;
}
sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`);
remaining = remaining.slice(chunk.length).trimStart();
}
};
const quit = (reason?: string) => {
if (closed) {
return;
}
closed = true;
const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
try {
if (safeReason) {
sendRaw(`QUIT :${safeReason}`);
} else {
sendRaw("QUIT");
}
} catch {
// Ignore quit failures while shutting down.
}
socket.end();
};
const close = () => {
if (closed) {
return;
}
closed = true;
socket.destroy();
};
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const rawLine = buffer.slice(0, idx).replace(/\r$/, "");
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf("\n");
if (!rawLine) {
continue;
}
if (options.onLine) {
options.onLine(rawLine);
}
const line = parseIrcLine(rawLine);
if (!line) {
continue;
}
if (line.command === "PING") {
const payload =
line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
sendRaw(`PONG :${payload}`);
continue;
}
if (line.command === "NICK") {
const prefix = parseIrcPrefix(line.prefix);
if (prefix.nick && prefix.nick.toLowerCase() === currentNick.toLowerCase()) {
const next =
line.trailing != null
? line.trailing
: line.params[0] != null
? line.params[0]
: currentNick;
currentNick = String(next).trim();
}
continue;
}
if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) {
if (tryRecoverNickCollision()) {
continue;
}
const detail =
line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use";
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
close();
return;
}
if (!ready && IRC_ERROR_CODES.has(line.command)) {
const detail =
line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected";
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
close();
return;
}
if (line.command === "001") {
ready = true;
const nickParam = line.params[0];
if (nickParam && nickParam.trim()) {
currentNick = nickParam.trim();
}
try {
const nickServCommands = buildIrcNickServCommands(options.nickserv);
for (const command of nickServCommands) {
sendRaw(command);
}
} catch (err) {
fail(err);
}
for (const channel of options.channels || []) {
const trimmed = channel.trim();
if (!trimmed) {
continue;
}
try {
join(trimmed);
} catch (err) {
fail(err);
}
}
if (resolveReady) {
resolveReady();
}
resolveReady = null;
rejectReady = null;
continue;
}
if (line.command === "NOTICE") {
if (options.onNotice) {
options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]);
}
continue;
}
if (line.command === "PRIVMSG") {
const targetParam = line.params[0];
const target = targetParam ? targetParam.trim() : "";
const text = line.trailing != null ? line.trailing : "";
const prefix = parseIrcPrefix(line.prefix);
const senderNick = prefix.nick ? prefix.nick.trim() : "";
if (!target || !senderNick || !text.trim()) {
continue;
}
if (options.onPrivmsg) {
void Promise.resolve(
options.onPrivmsg({
senderNick,
senderUser: prefix.user ? prefix.user.trim() : undefined,
senderHost: prefix.host ? prefix.host.trim() : undefined,
target,
text,
rawLine,
}),
).catch((error) => {
fail(error);
});
}
}
}
});
socket.once("connect", () => {
try {
if (options.password && options.password.trim()) {
sendRaw(`PASS ${options.password.trim()}`);
}
sendRaw(`NICK ${options.nick.trim()}`);
sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`);
} catch (err) {
fail(err);
close();
}
});
socket.once("error", (err) => {
fail(err);
});
socket.once("close", () => {
if (!closed) {
closed = true;
if (!ready) {
fail(new Error("IRC connection closed before ready"));
}
}
});
if (options.abortSignal) {
const abort = () => {
quit("shutdown");
};
if (options.abortSignal.aborted) {
abort();
} else {
options.abortSignal.addEventListener("abort", abort, { once: true });
}
}
await withTimeout(readyPromise, timeoutMs, "IRC connect");
return {
get nick() {
return currentNick;
},
isReady: () => ready && !closed,
sendRaw,
join,
sendPrivmsg,
quit,
close,
};
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { IrcConfigSchema } from "./config-schema.js";
describe("irc config schema", () => {
it("accepts numeric allowFrom and groupAllowFrom entries", () => {
const parsed = IrcConfigSchema.parse({
dmPolicy: "allowlist",
allowFrom: [12345, "alice"],
groupAllowFrom: [67890, "alice!ident@example.org"],
});
expect(parsed.allowFrom).toEqual([12345, "alice"]);
expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
});
it("accepts numeric per-channel allowFrom entries", () => {
const parsed = IrcConfigSchema.parse({
groups: {
"#ops": {
allowFrom: [42, "alice"],
},
},
});
expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
});
});

View File

@@ -0,0 +1,97 @@
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk";
import { z } from "zod";
const IrcGroupSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
})
.strict();
const IrcNickServSchema = z
.object({
enabled: z.boolean().optional(),
service: z.string().optional(),
password: z.string().optional(),
passwordFile: z.string().optional(),
register: z.boolean().optional(),
registerEmail: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.register && !value.registerEmail?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["registerEmail"],
message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
});
}
});
export const IrcAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
host: z.string().optional(),
port: z.number().int().min(1).max(65535).optional(),
tls: z.boolean().optional(),
nick: z.string().optional(),
username: z.string().optional(),
realname: z.string().optional(),
password: z.string().optional(),
passwordFile: z.string().optional(),
nickserv: IrcNickServSchema.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
channels: z.array(z.string()).optional(),
mentionPatterns: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
})
.strict();
export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
});
});
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
});
});

View File

@@ -0,0 +1,22 @@
export function isIrcControlChar(charCode: number): boolean {
return charCode <= 0x1f || charCode === 0x7f;
}
export function hasIrcControlChars(value: string): boolean {
for (const char of value) {
if (isIrcControlChar(char.charCodeAt(0))) {
return true;
}
}
return false;
}
export function stripIrcControlChars(value: string): string {
let out = "";
for (const char of value) {
if (!isIrcControlChar(char.charCodeAt(0))) {
out += char;
}
}
return out;
}

View File

@@ -0,0 +1,334 @@
import {
createReplyPrefixOptions,
logInboundDrop,
resolveControlCommandGate,
type OpenClawConfig,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { ResolvedIrcAccount } from "./accounts.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
import {
resolveIrcMentionGate,
resolveIrcGroupAccessGate,
resolveIrcGroupMatch,
resolveIrcGroupSenderAllowed,
resolveIrcRequireMention,
} from "./policy.js";
import { getIrcRuntime } from "./runtime.js";
import { sendMessageIrc } from "./send.js";
const CHANNEL_ID = "irc" as const;
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
async function deliverIrcReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
target: string;
accountId: string;
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}) {
const text = params.payload.text ?? "";
const mediaList = params.payload.mediaUrls?.length
? params.payload.mediaUrls
: params.payload.mediaUrl
? [params.payload.mediaUrl]
: [];
if (!text.trim() && mediaList.length === 0) {
return;
}
const mediaBlock = mediaList.length
? mediaList.map((url) => `Attachment: ${url}`).join("\n")
: "";
const combined = text.trim()
? mediaBlock
? `${text.trim()}\n\n${mediaBlock}`
: text.trim()
: mediaBlock;
if (params.sendReply) {
await params.sendReply(params.target, combined, params.payload.replyToId);
} else {
await sendMessageIrc(params.target, combined, {
accountId: params.accountId,
replyTo: params.payload.replyToId,
});
}
params.statusSink?.({ lastOutboundAt: Date.now() });
}
export async function handleIrcInbound(params: {
message: IrcInboundMessage;
account: ResolvedIrcAccount;
config: CoreConfig;
runtime: RuntimeEnv;
connectedNick?: string;
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { message, account, config, runtime, connectedNick, statusSink } = params;
const core = getIrcRuntime();
const rawBody = message.text?.trim() ?? "";
if (!rawBody) {
return;
}
statusSink?.({ lastInboundAt: message.timestamp });
const senderDisplay = message.senderHost
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
: message.senderNick;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
const groupMatch = resolveIrcGroupMatch({
groups: account.config.groups,
target: message.target,
});
if (message.isGroup) {
const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch });
if (!groupAccess.allowed) {
runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`);
return;
}
}
const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom);
const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom);
const groupAllowFrom =
directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as OpenClawConfig,
surface: CHANNEL_ID,
});
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveIrcAllowlistMatch({
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
message,
}).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{
configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
allowed: senderAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = commandGate.commandAuthorized;
if (message.isGroup) {
const senderAllowed = resolveIrcGroupSenderAllowed({
groupPolicy,
message,
outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: groupAllowFrom,
});
if (!senderAllowed) {
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
return;
}
} else {
if (dmPolicy === "disabled") {
runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const dmAllowed = resolveIrcAllowlistMatch({
allowFrom: effectiveAllowFrom,
message,
}).allowed;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderDisplay.toLowerCase(),
meta: { name: message.senderNick || undefined },
});
if (created) {
try {
const reply = core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `Your IRC id: ${senderDisplay}`,
code,
});
await deliverIrcReply({
payload: { text: reply },
target: message.senderNick,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
} catch (err) {
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
}
}
}
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
return;
}
}
}
if (message.isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (line) => runtime.log?.(line),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderDisplay,
});
return;
}
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
const mentionNick = connectedNick?.trim() || account.nick;
const explicitMentionRegex = mentionNick
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
: null;
const wasMentioned =
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
const requireMention = message.isGroup
? resolveIrcRequireMention({
groupConfig: groupMatch.groupConfig,
wildcardConfig: groupMatch.wildcardConfig,
})
: false;
const mentionGate = resolveIrcMentionGate({
isGroup: message.isGroup,
requireMention,
wasMentioned,
hasControlCommand,
allowTextCommands,
commandAuthorized,
});
if (mentionGate.shouldSkip) {
runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`);
return;
}
const peerId = message.isGroup ? message.target : message.senderNick;
const route = core.channel.routing.resolveAgentRoute({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: message.isGroup ? "group" : "direct",
id: peerId,
},
});
const fromLabel = message.isGroup ? message.target : senderDisplay;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "IRC",
from: fromLabel,
timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const groupSystemPrompt = groupMatch.groupConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: message.isGroup ? `irc:channel:${message.target}` : `irc:${senderDisplay}`,
To: `irc:${peerId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: message.isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: message.senderNick || undefined,
SenderId: senderDisplay,
GroupSubject: message.isGroup ? message.target : undefined,
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
WasMentioned: message.isGroup ? wasMentioned : undefined,
MessageSid: message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `irc:${peerId}`,
CommandAuthorized: commandAuthorized,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
},
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg: config as OpenClawConfig,
agentId: route.agentId,
channel: CHANNEL_ID,
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as OpenClawConfig,
dispatcherOptions: {
...prefixOptions,
deliver: async (payload) => {
await deliverIrcReply({
payload: payload as {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
},
target: peerId,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
skillFilter: groupMatch.groupConfig?.skills,
onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
});
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { resolveIrcInboundTarget } from "./monitor.js";
describe("irc monitor inbound target", () => {
it("keeps channel target for group messages", () => {
expect(
resolveIrcInboundTarget({
target: "#openclaw",
senderNick: "alice",
}),
).toEqual({
isGroup: true,
target: "#openclaw",
rawTarget: "#openclaw",
});
});
it("maps DM target to sender nick and preserves raw target", () => {
expect(
resolveIrcInboundTarget({
target: "openclaw-bot",
senderNick: "alice",
}),
).toEqual({
isGroup: false,
target: "alice",
rawTarget: "openclaw-bot",
});
});
it("falls back to raw target when sender nick is empty", () => {
expect(
resolveIrcInboundTarget({
target: "openclaw-bot",
senderNick: " ",
}),
).toEqual({
isGroup: false,
target: "openclaw-bot",
rawTarget: "openclaw-bot",
});
});
});

View File

@@ -0,0 +1,158 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient, type IrcClient } from "./client.js";
import { handleIrcInbound } from "./inbound.js";
import { isChannelTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
export type IrcMonitorOptions = {
accountId?: string;
config?: CoreConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
};
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
isGroup: boolean;
target: string;
rawTarget: string;
} {
const rawTarget = params.target;
const isGroup = isChannelTarget(rawTarget);
if (isGroup) {
return { isGroup: true, target: rawTarget, rawTarget };
}
const senderNick = params.senderNick.trim();
return { isGroup: false, target: senderNick || rawTarget, rawTarget };
}
export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
const core = getIrcRuntime();
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
const account = resolveIrcAccount({
cfg,
accountId: opts.accountId,
});
const runtime: RuntimeEnv = opts.runtime ?? {
log: (message: string) => core.logging.getChildLogger().info(message),
error: (message: string) => core.logging.getChildLogger().error(message),
exit: () => {
throw new Error("Runtime exit not available");
},
};
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
const logger = core.logging.getChildLogger({
channel: "irc",
accountId: account.accountId,
});
let client: IrcClient | null = null;
client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
channels: account.config.channels,
abortSignal: opts.abortSignal,
onLine: (line) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] << ${line}`);
}
},
onNotice: (text, target) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
}
},
onError: (error) => {
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
},
onPrivmsg: async (event) => {
if (!client) {
return;
}
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
return;
}
const inboundTarget = resolveIrcInboundTarget({
target: event.target,
senderNick: event.senderNick,
});
const message: IrcInboundMessage = {
messageId: makeIrcMessageId(),
target: inboundTarget.target,
rawTarget: inboundTarget.rawTarget,
senderNick: event.senderNick,
senderUser: event.senderUser,
senderHost: event.senderHost,
text: event.text,
timestamp: Date.now(),
isGroup: inboundTarget.isGroup,
};
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message, client);
return;
}
await handleIrcInbound({
message,
account,
config: cfg,
runtime,
connectedNick: client.nick,
sendReply: async (target, text) => {
client?.sendPrivmsg(target, text);
opts.statusSink?.({ lastOutboundAt: Date.now() });
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
},
statusSink: opts.statusSink,
});
},
});
logger.info(
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
);
return {
stop: () => {
client?.quit("shutdown");
client = null;
},
};
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
buildIrcAllowlistCandidates,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
resolveIrcAllowlistMatch,
} from "./normalize.js";
describe("irc normalize", () => {
it("normalizes targets", () => {
expect(normalizeIrcMessagingTarget("irc:channel:openclaw")).toBe("#openclaw");
expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice");
expect(normalizeIrcMessagingTarget("\n")).toBeUndefined();
});
it("normalizes allowlist entries", () => {
expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h");
});
it("matches senders by nick/user/host candidates", () => {
const message = {
messageId: "m1",
target: "#chan",
senderNick: "Alice",
senderUser: "ident",
senderHost: "example.org",
text: "hi",
timestamp: Date.now(),
isGroup: true,
};
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
expect(
resolveIrcAllowlistMatch({
allowFrom: ["alice!ident@example.org"],
message,
}).allowed,
).toBe(true);
expect(
resolveIrcAllowlistMatch({
allowFrom: ["bob"],
message,
}).allowed,
).toBe(false);
});
});

View File

@@ -0,0 +1,117 @@
import type { IrcInboundMessage } from "./types.js";
import { hasIrcControlChars } from "./control-chars.js";
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
export function isChannelTarget(target: string): boolean {
return target.startsWith("#") || target.startsWith("&");
}
export function normalizeIrcMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
let target = trimmed;
const lowered = target.toLowerCase();
if (lowered.startsWith("irc:")) {
target = target.slice("irc:".length).trim();
}
if (target.toLowerCase().startsWith("channel:")) {
target = target.slice("channel:".length).trim();
if (!target.startsWith("#") && !target.startsWith("&")) {
target = `#${target}`;
}
}
if (target.toLowerCase().startsWith("user:")) {
target = target.slice("user:".length).trim();
}
if (!target || !looksLikeIrcTargetId(target)) {
return undefined;
}
return target;
}
export function looksLikeIrcTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (hasIrcControlChars(trimmed)) {
return false;
}
return IRC_TARGET_PATTERN.test(trimmed);
}
export function normalizeIrcAllowEntry(raw: string): string {
let value = raw.trim().toLowerCase();
if (!value) {
return "";
}
if (value.startsWith("irc:")) {
value = value.slice("irc:".length);
}
if (value.startsWith("user:")) {
value = value.slice("user:".length);
}
return value.trim();
}
export function normalizeIrcAllowlist(entries?: Array<string | number>): string[] {
return (entries ?? []).map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean);
}
export function formatIrcSenderId(message: IrcInboundMessage): string {
const base = message.senderNick.trim();
const user = message.senderUser?.trim();
const host = message.senderHost?.trim();
if (user && host) {
return `${base}!${user}@${host}`;
}
if (user) {
return `${base}!${user}`;
}
if (host) {
return `${base}@${host}`;
}
return base;
}
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] {
const nick = message.senderNick.trim().toLowerCase();
const user = message.senderUser?.trim().toLowerCase();
const host = message.senderHost?.trim().toLowerCase();
const candidates = new Set<string>();
if (nick) {
candidates.add(nick);
}
if (nick && user) {
candidates.add(`${nick}!${user}`);
}
if (nick && host) {
candidates.add(`${nick}@${host}`);
}
if (nick && user && host) {
candidates.add(`${nick}!${user}@${host}`);
}
return [...candidates];
}
export function resolveIrcAllowlistMatch(params: {
allowFrom: string[];
message: IrcInboundMessage;
}): { allowed: boolean; source?: string } {
const allowFrom = new Set(
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
);
if (allowFrom.has("*")) {
return { allowed: true, source: "wildcard" };
}
const candidates = buildIrcAllowlistCandidates(params.message);
for (const candidate of candidates) {
if (allowFrom.has(candidate)) {
return { allowed: true, source: candidate };
}
}
return { allowed: false };
}

View File

@@ -0,0 +1,118 @@
import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "./types.js";
import { ircOnboardingAdapter } from "./onboarding.js";
describe("irc onboarding", () => {
it("configures host and nick via onboarding prompts", async () => {
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: vi.fn(async () => "allowlist"),
multiselect: vi.fn(async () => []),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "IRC server host") {
return "irc.libera.chat";
}
if (message === "IRC server port") {
return "6697";
}
if (message === "IRC nick") {
return "openclaw-bot";
}
if (message === "IRC username") {
return "openclaw";
}
if (message === "IRC real name") {
return "OpenClaw Bot";
}
if (message.startsWith("Auto-join IRC channels")) {
return "#openclaw, #ops";
}
if (message.startsWith("IRC channels allowlist")) {
return "#openclaw, #ops";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Use TLS for IRC?") {
return true;
}
if (message === "Configure IRC channels access?") {
return true;
}
return false;
}),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await ircOnboardingAdapter.configure({
cfg: {} as CoreConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.irc?.enabled).toBe(true);
expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot");
expect(result.cfg.channels?.irc?.tls).toBe(true);
expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]);
expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]);
});
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: vi.fn(async () => "allowlist"),
multiselect: vi.fn(async () => []),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "IRC allowFrom (nick or nick!user@host)") {
return "Alice, Bob!ident@example.org";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom;
expect(promptAllowFrom).toBeTypeOf("function");
const cfg: CoreConfig = {
channels: {
irc: {
accounts: {
work: {
host: "irc.libera.chat",
nick: "openclaw-work",
},
},
},
},
};
const updated = (await promptAllowFrom?.({
cfg,
prompter,
accountId: "work",
})) as CoreConfig;
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
});
});

View File

@@ -0,0 +1,479 @@
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
promptAccountId,
promptChannelAccessConfig,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type DmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
import {
isChannelTarget,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
} from "./normalize.js";
const channel = "irc" as const;
function parseListInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function parsePort(raw: string, fallback: number): number {
const trimmed = raw.trim();
if (!trimmed) {
return fallback;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
return fallback;
}
return parsed;
}
function normalizeGroupEntry(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (trimmed === "*") {
return "*";
}
const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed;
if (isChannelTarget(normalized)) {
return normalized;
}
return `#${normalized.replace(/^#+/, "")}`;
}
function updateIrcAccountConfig(
cfg: CoreConfig,
accountId: string,
patch: Partial<IrcAccountConfig>,
): CoreConfig {
const current = cfg.channels?.irc ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...current,
...patch,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...current,
accounts: {
...current.accounts,
[accountId]: {
...current.accounts?.[accountId],
...patch,
},
},
},
},
};
}
function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.irc?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...cfg.channels?.irc,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...cfg.channels?.irc,
allowFrom,
},
},
};
}
function setIrcNickServ(
cfg: CoreConfig,
accountId: string,
nickserv?: IrcNickServConfig,
): CoreConfig {
return updateIrcAccountConfig(cfg, accountId, { nickserv });
}
function setIrcGroupAccess(
cfg: CoreConfig,
accountId: string,
policy: "open" | "allowlist" | "disabled",
entries: string[],
): CoreConfig {
if (policy !== "allowlist") {
return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy });
}
const normalizedEntries = [
...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)),
];
const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
return updateIrcAccountConfig(cfg, accountId, {
enabled: true,
groupPolicy: "allowlist",
groups,
});
}
async function noteIrcSetupHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"IRC needs server host + bot nick.",
"Recommended: TLS on port 6697.",
"Optional: NickServ identify/register can be configured in onboarding.",
'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.',
'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).',
"Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.",
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
].join("\n"),
"IRC setup",
);
}
async function promptIrcAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const existing = params.cfg.channels?.irc?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist IRC DMs by sender.",
"Examples:",
"- alice",
"- alice!ident@example.org",
"Multiple entries: comma-separated.",
].join("\n"),
"IRC allowlist",
);
const raw = await params.prompter.text({
message: "IRC allowFrom (nick or nick!user@host)",
placeholder: "alice, bob!ident@example.org",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parsed = parseListInput(String(raw));
const normalized = [
...new Set(
parsed
.map((entry) => normalizeIrcAllowEntry(entry))
.map((entry) => entry.trim())
.filter(Boolean),
),
];
return setIrcAllowFrom(params.cfg, normalized);
}
async function promptIrcNickServConfig(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId });
const existing = resolved.config.nickserv;
const hasExisting = Boolean(existing?.password || existing?.passwordFile);
const wants = await params.prompter.confirm({
message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?",
initialValue: hasExisting,
});
if (!wants) {
return params.cfg;
}
const service = String(
await params.prompter.text({
message: "NickServ service nick",
initialValue: existing?.service || "NickServ",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const useEnvPassword =
params.accountId === DEFAULT_ACCOUNT_ID &&
Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) &&
!(existing?.password || existing?.passwordFile)
? await params.prompter.confirm({
message: "IRC_NICKSERV_PASSWORD detected. Use env var?",
initialValue: true,
})
: false;
const password = useEnvPassword
? undefined
: String(
await params.prompter.text({
message: "NickServ password (blank to disable NickServ auth)",
validate: () => undefined,
}),
).trim();
if (!password && !useEnvPassword) {
return setIrcNickServ(params.cfg, params.accountId, {
enabled: false,
service,
});
}
const register = await params.prompter.confirm({
message: "Send NickServ REGISTER on connect?",
initialValue: existing?.register ?? false,
});
const registerEmail = register
? String(
await params.prompter.text({
message: "NickServ register email",
initialValue:
existing?.registerEmail ||
(params.accountId === DEFAULT_ACCOUNT_ID
? process.env.IRC_NICKSERV_REGISTER_EMAIL
: undefined),
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim()
: undefined;
return setIrcNickServ(params.cfg, params.accountId, {
enabled: true,
service,
...(password ? { password } : {}),
register,
...(registerEmail ? { registerEmail } : {}),
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "IRC",
channel,
policyKey: "channels.irc.dmPolicy",
allowFromKey: "channels.irc.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptIrcAllowFrom,
};
export const ircOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const coreCfg = cfg as CoreConfig;
const configured = listIrcAccountIds(coreCfg).some(
(accountId) => resolveIrcAccount({ cfg: coreCfg, accountId }).configured,
);
return {
channel,
configured,
statusLines: [`IRC: ${configured ? "configured" : "needs host + nick"}`],
selectionHint: configured ? "configured" : "needs host + nick",
quickstartScore: configured ? 1 : 0,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
let next = cfg as CoreConfig;
const ircOverride = accountOverrides.irc?.trim();
const defaultAccountId = resolveDefaultIrcAccountId(next);
let accountId = ircOverride || defaultAccountId;
if (shouldPromptAccountIds && !ircOverride) {
accountId = await promptAccountId({
cfg: next,
prompter,
label: "IRC",
currentId: accountId,
listAccountIds: listIrcAccountIds,
defaultAccountId,
});
}
const resolved = resolveIrcAccount({ cfg: next, accountId });
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : "";
const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : "";
const envReady = Boolean(envHost && envNick);
if (!resolved.configured) {
await noteIrcSetupHelp(prompter);
}
let useEnv = false;
if (envReady && isDefaultAccount && !resolved.config.host && !resolved.config.nick) {
useEnv = await prompter.confirm({
message: "IRC_HOST and IRC_NICK detected. Use env vars?",
initialValue: true,
});
}
if (useEnv) {
next = updateIrcAccountConfig(next, accountId, { enabled: true });
} else {
const host = String(
await prompter.text({
message: "IRC server host",
initialValue: resolved.config.host || envHost || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const tls = await prompter.confirm({
message: "Use TLS for IRC?",
initialValue: resolved.config.tls ?? true,
});
const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667);
const portInput = await prompter.text({
message: "IRC server port",
initialValue: String(defaultPort),
validate: (value) => {
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535
? undefined
: "Use a port between 1 and 65535";
},
});
const port = parsePort(String(portInput), defaultPort);
const nick = String(
await prompter.text({
message: "IRC nick",
initialValue: resolved.config.nick || envNick || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const username = String(
await prompter.text({
message: "IRC username",
initialValue: resolved.config.username || nick || "openclaw",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const realname = String(
await prompter.text({
message: "IRC real name",
initialValue: resolved.config.realname || "OpenClaw",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const channelsRaw = await prompter.text({
message: "Auto-join IRC channels (optional, comma-separated)",
placeholder: "#openclaw, #ops",
initialValue: (resolved.config.channels ?? []).join(", "),
});
const channels = [
...new Set(
parseListInput(String(channelsRaw))
.map((entry) => normalizeGroupEntry(entry))
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.filter((entry) => isChannelTarget(entry)),
),
];
next = updateIrcAccountConfig(next, accountId, {
enabled: true,
host,
port,
tls,
nick,
username,
realname,
channels: channels.length > 0 ? channels : undefined,
});
}
const afterConfig = resolveIrcAccount({ cfg: next, accountId });
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "IRC channels",
currentPolicy: afterConfig.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(afterConfig.config.groups ?? {}),
placeholder: "#openclaw, #ops, *",
updatePrompt: Boolean(afterConfig.config.groups),
});
if (accessConfig) {
next = setIrcGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries);
// Mention gating: groups/channels are mention-gated by default. Make this explicit in onboarding.
const wantsMentions = await prompter.confirm({
message: "Require @mention to reply in IRC channels?",
initialValue: true,
});
if (!wantsMentions) {
const resolvedAfter = resolveIrcAccount({ cfg: next, accountId });
const groups = resolvedAfter.config.groups ?? {};
const patched = Object.fromEntries(
Object.entries(groups).map(([key, value]) => [key, { ...value, requireMention: false }]),
);
next = updateIrcAccountConfig(next, accountId, { groups: patched });
}
}
if (forceAllowFrom) {
next = await promptIrcAllowFrom({ cfg: next, prompter, accountId });
}
next = await promptIrcNickServConfig({
cfg: next,
prompter,
accountId,
});
await prompter.note(
[
"Next: restart gateway and verify status.",
"Command: openclaw channels status --probe",
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
].join("\n"),
"IRC next steps",
);
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...(cfg as CoreConfig),
channels: {
...(cfg as CoreConfig).channels,
irc: {
...(cfg as CoreConfig).channels?.irc,
enabled: false,
},
},
}),
};

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { resolveChannelGroupPolicy } from "../../../src/config/group-policy.js";
import {
resolveIrcGroupAccessGate,
resolveIrcGroupMatch,
resolveIrcGroupSenderAllowed,
resolveIrcMentionGate,
resolveIrcRequireMention,
} from "./policy.js";
describe("irc policy", () => {
it("matches direct and wildcard group entries", () => {
const direct = resolveIrcGroupMatch({
groups: {
"#ops": { requireMention: false },
},
target: "#ops",
});
expect(direct.allowed).toBe(true);
expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false);
const wildcard = resolveIrcGroupMatch({
groups: {
"*": { requireMention: true },
},
target: "#random",
});
expect(wildcard.allowed).toBe(true);
expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
});
it("enforces allowlist by default in groups", () => {
const message = {
messageId: "m1",
target: "#ops",
senderNick: "alice",
senderUser: "ident",
senderHost: "example.org",
text: "hi",
timestamp: Date.now(),
isGroup: true,
};
expect(
resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist",
message,
outerAllowFrom: [],
innerAllowFrom: [],
}),
).toBe(false);
expect(
resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist",
message,
outerAllowFrom: ["alice"],
innerAllowFrom: [],
}),
).toBe(true);
});
it('allows unconfigured channels when groupPolicy is "open"', () => {
const groupMatch = resolveIrcGroupMatch({
groups: undefined,
target: "#random",
});
const gate = resolveIrcGroupAccessGate({
groupPolicy: "open",
groupMatch,
});
expect(gate.allowed).toBe(true);
expect(gate.reason).toBe("open");
});
it("honors explicit group disable even in open mode", () => {
const groupMatch = resolveIrcGroupMatch({
groups: {
"#ops": { enabled: false },
},
target: "#ops",
});
const gate = resolveIrcGroupAccessGate({
groupPolicy: "open",
groupMatch,
});
expect(gate.allowed).toBe(false);
expect(gate.reason).toBe("disabled");
});
it("allows authorized control commands without mention", () => {
const gate = resolveIrcMentionGate({
isGroup: true,
requireMention: true,
wasMentioned: false,
hasControlCommand: true,
allowTextCommands: true,
commandAuthorized: true,
});
expect(gate.shouldSkip).toBe(false);
});
it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
const groups = {
"#Ops": { requireMention: false },
"#Hidden": { enabled: false },
"*": { requireMention: true },
};
const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" });
const sharedDirect = resolveChannelGroupPolicy({
cfg: { channels: { irc: { groups } } },
channel: "irc",
groupId: "#ops",
groupIdCaseInsensitive: true,
});
expect(sharedDirect.allowed).toBe(inboundDirect.allowed);
expect(sharedDirect.groupConfig?.requireMention).toBe(
inboundDirect.groupConfig?.requireMention,
);
const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" });
const sharedDisabled = resolveChannelGroupPolicy({
cfg: { channels: { irc: { groups } } },
channel: "irc",
groupId: "#hidden",
groupIdCaseInsensitive: true,
});
expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed);
expect(sharedDisabled.groupConfig?.enabled).toBe(inboundDisabled.groupConfig?.enabled);
});
});

View File

@@ -0,0 +1,157 @@
import type { IrcAccountConfig, IrcChannelConfig } from "./types.js";
import type { IrcInboundMessage } from "./types.js";
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
export type IrcGroupMatch = {
allowed: boolean;
groupConfig?: IrcChannelConfig;
wildcardConfig?: IrcChannelConfig;
hasConfiguredGroups: boolean;
};
export type IrcGroupAccessGate = {
allowed: boolean;
reason: string;
};
export function resolveIrcGroupMatch(params: {
groups?: Record<string, IrcChannelConfig>;
target: string;
}): IrcGroupMatch {
const groups = params.groups ?? {};
const hasConfiguredGroups = Object.keys(groups).length > 0;
// IRC channel targets are case-insensitive, but config keys are plain strings.
// To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match
// group config keys case-insensitively.
const direct = groups[params.target];
if (direct) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are handled later by resolveIrcGroupAccessGate.
allowed: true,
groupConfig: direct,
wildcardConfig: groups["*"],
hasConfiguredGroups,
};
}
const targetLower = params.target.toLowerCase();
const directKey = Object.keys(groups).find((key) => key.toLowerCase() === targetLower);
if (directKey) {
const matched = groups[directKey];
if (matched) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are handled later by resolveIrcGroupAccessGate.
allowed: true,
groupConfig: matched,
wildcardConfig: groups["*"],
hasConfiguredGroups,
};
}
}
const wildcard = groups["*"];
if (wildcard) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are handled later by resolveIrcGroupAccessGate.
allowed: true,
wildcardConfig: wildcard,
hasConfiguredGroups,
};
}
return {
allowed: false,
hasConfiguredGroups,
};
}
export function resolveIrcGroupAccessGate(params: {
groupPolicy: IrcAccountConfig["groupPolicy"];
groupMatch: IrcGroupMatch;
}): IrcGroupAccessGate {
const policy = params.groupPolicy ?? "allowlist";
if (policy === "disabled") {
return { allowed: false, reason: "groupPolicy=disabled" };
}
// In open mode, unconfigured channels are allowed (mention-gated) but explicit
// per-channel/wildcard disables still apply.
if (policy === "allowlist") {
if (!params.groupMatch.hasConfiguredGroups) {
return {
allowed: false,
reason: "groupPolicy=allowlist and no groups configured",
};
}
if (!params.groupMatch.allowed) {
return { allowed: false, reason: "not allowlisted" };
}
}
if (
params.groupMatch.groupConfig?.enabled === false ||
params.groupMatch.wildcardConfig?.enabled === false
) {
return { allowed: false, reason: "disabled" };
}
return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" };
}
export function resolveIrcRequireMention(params: {
groupConfig?: IrcChannelConfig;
wildcardConfig?: IrcChannelConfig;
}): boolean {
if (params.groupConfig?.requireMention !== undefined) {
return params.groupConfig.requireMention;
}
if (params.wildcardConfig?.requireMention !== undefined) {
return params.wildcardConfig.requireMention;
}
return true;
}
export function resolveIrcMentionGate(params: {
isGroup: boolean;
requireMention: boolean;
wasMentioned: boolean;
hasControlCommand: boolean;
allowTextCommands: boolean;
commandAuthorized: boolean;
}): { shouldSkip: boolean; reason: string } {
if (!params.isGroup) {
return { shouldSkip: false, reason: "direct" };
}
if (!params.requireMention) {
return { shouldSkip: false, reason: "mention-not-required" };
}
if (params.wasMentioned) {
return { shouldSkip: false, reason: "mentioned" };
}
if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) {
return { shouldSkip: false, reason: "authorized-command" };
}
return { shouldSkip: true, reason: "missing-mention" };
}
export function resolveIrcGroupSenderAllowed(params: {
groupPolicy: IrcAccountConfig["groupPolicy"];
message: IrcInboundMessage;
outerAllowFrom: string[];
innerAllowFrom: string[];
}): boolean {
const policy = params.groupPolicy ?? "allowlist";
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
if (inner.length > 0) {
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed;
}
if (outer.length > 0) {
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed;
}
return policy === "open";
}

View File

@@ -0,0 +1,64 @@
import type { CoreConfig, IrcProbe } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return typeof err === "string" ? err : JSON.stringify(err);
}
export async function probeIrc(
cfg: CoreConfig,
opts?: { accountId?: string; timeoutMs?: number },
): Promise<IrcProbe> {
const account = resolveIrcAccount({ cfg, accountId: opts?.accountId });
const base: IrcProbe = {
ok: false,
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
};
if (!account.configured) {
return {
...base,
error: "missing host or nick",
};
}
const started = Date.now();
try {
const client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: opts?.timeoutMs ?? 8000,
});
const elapsed = Date.now() - started;
client.quit("probe");
return {
...base,
ok: true,
latencyMs: elapsed,
};
} catch (err) {
return {
...base,
error: formatError(err),
};
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
parseIrcLine,
parseIrcPrefix,
sanitizeIrcOutboundText,
sanitizeIrcTarget,
splitIrcText,
} from "./protocol.js";
describe("irc protocol", () => {
it("parses PRIVMSG lines with prefix and trailing", () => {
const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world");
expect(parsed).toEqual({
raw: ":alice!u@host PRIVMSG #room :hello world",
prefix: "alice!u@host",
command: "PRIVMSG",
params: ["#room"],
trailing: "hello world",
});
expect(parseIrcPrefix(parsed?.prefix)).toEqual({
nick: "alice",
user: "u",
host: "host",
});
});
it("sanitizes outbound text to prevent command injection", () => {
expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops");
expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test");
});
it("validates targets and rejects control characters", () => {
expect(sanitizeIrcTarget("#openclaw")).toBe("#openclaw");
expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/);
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
});
it("splits long text on boundaries", () => {
const chunks = splitIrcText("a ".repeat(300), 120);
expect(chunks.length).toBeGreaterThan(2);
expect(chunks.every((chunk) => chunk.length <= 120)).toBe(true);
});
});

View File

@@ -0,0 +1,169 @@
import { randomUUID } from "node:crypto";
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
export type ParsedIrcLine = {
raw: string;
prefix?: string;
command: string;
params: string[];
trailing?: string;
};
export type ParsedIrcPrefix = {
nick?: string;
user?: string;
host?: string;
server?: string;
};
export function parseIrcLine(line: string): ParsedIrcLine | null {
const raw = line.replace(/[\r\n]+/g, "").trim();
if (!raw) {
return null;
}
let cursor = raw;
let prefix: string | undefined;
if (cursor.startsWith(":")) {
const idx = cursor.indexOf(" ");
if (idx <= 1) {
return null;
}
prefix = cursor.slice(1, idx);
cursor = cursor.slice(idx + 1).trimStart();
}
if (!cursor) {
return null;
}
const firstSpace = cursor.indexOf(" ");
const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim();
if (!command) {
return null;
}
cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1);
const params: string[] = [];
let trailing: string | undefined;
while (cursor.length > 0) {
cursor = cursor.trimStart();
if (!cursor) {
break;
}
if (cursor.startsWith(":")) {
trailing = cursor.slice(1);
break;
}
const spaceIdx = cursor.indexOf(" ");
if (spaceIdx === -1) {
params.push(cursor);
break;
}
params.push(cursor.slice(0, spaceIdx));
cursor = cursor.slice(spaceIdx + 1);
}
return {
raw,
prefix,
command: command.toUpperCase(),
params,
trailing,
};
}
export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
if (!prefix) {
return {};
}
const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/);
if (nickPart) {
return {
nick: nickPart[1],
user: nickPart[2],
host: nickPart[3],
};
}
const nickHostPart = prefix.match(/^([^@]+)@(.+)$/);
if (nickHostPart) {
return {
nick: nickHostPart[1],
host: nickHostPart[2],
};
}
if (prefix.includes("!")) {
const [nick, user] = prefix.split("!", 2);
return { nick, user };
}
if (prefix.includes(".")) {
return { server: prefix };
}
return { nick: prefix };
}
function decodeLiteralEscapes(input: string): string {
// Defensive: this is not a full JS string unescaper.
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
return input
.replace(/\\r/g, "\r")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\0/g, "\0")
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
}
export function sanitizeIrcOutboundText(text: string): string {
const decoded = decodeLiteralEscapes(text);
return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim();
}
export function sanitizeIrcTarget(raw: string): string {
const decoded = decodeLiteralEscapes(raw);
if (!decoded) {
throw new Error("IRC target is required");
}
// Reject any surrounding whitespace instead of trimming it away.
if (decoded !== decoded.trim()) {
throw new Error(`Invalid IRC target: ${raw}`);
}
if (hasIrcControlChars(decoded)) {
throw new Error(`Invalid IRC target: ${raw}`);
}
if (!IRC_TARGET_PATTERN.test(decoded)) {
throw new Error(`Invalid IRC target: ${raw}`);
}
return decoded;
}
export function splitIrcText(text: string, maxChars = 350): string[] {
const cleaned = sanitizeIrcOutboundText(text);
if (!cleaned) {
return [];
}
if (cleaned.length <= maxChars) {
return [cleaned];
}
const chunks: string[] = [];
let remaining = cleaned;
while (remaining.length > maxChars) {
let splitAt = remaining.lastIndexOf(" ", maxChars);
if (splitAt < Math.floor(maxChars * 0.5)) {
splitAt = maxChars;
}
chunks.push(remaining.slice(0, splitAt).trim());
remaining = remaining.slice(splitAt).trimStart();
}
if (remaining) {
chunks.push(remaining);
}
return chunks.filter(Boolean);
}
export function makeIrcMessageId() {
return randomUUID();
}

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setIrcRuntime(next: PluginRuntime) {
runtime = next;
}
export function getIrcRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("IRC runtime not initialized");
}
return runtime;
}

View File

@@ -0,0 +1,99 @@
import type { IrcClient } from "./client.js";
import type { CoreConfig } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { normalizeIrcMessagingTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
type SendIrcOptions = {
accountId?: string;
replyTo?: string;
target?: string;
client?: IrcClient;
};
export type SendIrcResult = {
messageId: string;
target: string;
};
function resolveTarget(to: string, opts?: SendIrcOptions): string {
const fromArg = normalizeIrcMessagingTarget(to);
if (fromArg) {
return fromArg;
}
const fromOpt = normalizeIrcMessagingTarget(opts?.target ?? "");
if (fromOpt) {
return fromOpt;
}
throw new Error(`Invalid IRC target: ${to}`);
}
export async function sendMessageIrc(
to: string,
text: string,
opts: SendIrcOptions = {},
): Promise<SendIrcResult> {
const runtime = getIrcRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const account = resolveIrcAccount({
cfg,
accountId: opts.accountId,
});
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
const target = resolveTarget(to, opts);
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
cfg,
channel: "irc",
accountId: account.accountId,
});
const prepared = runtime.channel.text.convertMarkdownTables(text.trim(), tableMode);
const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared;
if (!payload.trim()) {
throw new Error("Message must be non-empty for IRC sends");
}
const client = opts.client;
if (client?.isReady()) {
client.sendPrivmsg(target, payload);
} else {
const transient = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: 12000,
});
transient.sendPrivmsg(target, payload);
transient.quit("sent");
}
runtime.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
return {
messageId: makeIrcMessageId(),
target,
};
}

View File

@@ -0,0 +1,94 @@
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
MarkdownConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk";
export type IrcChannelConfig = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
skills?: string[];
enabled?: boolean;
allowFrom?: Array<string | number>;
systemPrompt?: string;
};
export type IrcNickServConfig = {
enabled?: boolean;
service?: string;
password?: string;
passwordFile?: string;
register?: boolean;
registerEmail?: string;
};
export type IrcAccountConfig = {
name?: string;
enabled?: boolean;
host?: string;
port?: number;
tls?: boolean;
nick?: string;
username?: string;
realname?: string;
password?: string;
passwordFile?: string;
nickserv?: IrcNickServConfig;
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
groupPolicy?: GroupPolicy;
groupAllowFrom?: Array<string | number>;
groups?: Record<string, IrcChannelConfig>;
channels?: string[];
mentionPatterns?: string[];
markdown?: MarkdownConfig;
historyLimit?: number;
dmHistoryLimit?: number;
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
responsePrefix?: string;
mediaMaxMb?: number;
};
export type IrcConfig = IrcAccountConfig & {
accounts?: Record<string, IrcAccountConfig>;
};
export type CoreConfig = OpenClawConfig & {
channels?: OpenClawConfig["channels"] & {
irc?: IrcConfig;
};
};
export type IrcInboundMessage = {
messageId: string;
/** Conversation peer id: channel name for groups, sender nick for DMs. */
target: string;
/** Raw IRC PRIVMSG target (bot nick for DMs, channel for groups). */
rawTarget?: string;
senderNick: string;
senderUser?: string;
senderHost?: string;
text: string;
timestamp: number;
isGroup: boolean;
};
export type IrcProbe = {
ok: boolean;
host: string;
port: number;
tls: boolean;
nick: string;
latencyMs?: number;
error?: string;
};

View File

@@ -12,6 +12,7 @@ import {
} from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import { fetchEventSummary } from "../actions/summary.js";
import {
formatPollAsText,
isPollStartType,
@@ -431,7 +432,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
});
const route = core.channel.routing.resolveAgentRoute({
const baseRoute = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
peer: {
@@ -439,8 +440,57 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
id: isDirectMessage ? senderId : roomId,
},
});
const route = {
...baseRoute,
sessionKey: threadRootId
? `${baseRoute.sessionKey}:thread:${threadRootId}`
: baseRoute.sessionKey,
};
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let parentSessionKey: string | undefined;
if (threadRootId) {
const existingSession = core.channel.session.readSessionUpdatedAt({
storePath: core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: baseRoute.agentId,
}),
sessionKey: route.sessionKey,
});
if (existingSession === undefined) {
try {
const rootEvent = await fetchEventSummary(client, roomId, threadRootId);
if (rootEvent?.body) {
const rootSenderName = rootEvent.sender
? await getMemberDisplayName(roomId, rootEvent.sender)
: undefined;
threadStarterBody = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: rootSenderName ?? rootEvent.sender ?? "Unknown",
timestamp: rootEvent.timestamp,
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
body: rootEvent.body,
});
threadLabel = `Matrix thread in ${roomName ?? roomId}`;
parentSessionKey = baseRoute.sessionKey;
}
} catch (err) {
logVerboseMessage(
`matrix: failed to fetch thread root ${threadRootId}: ${String(err)}`,
);
}
}
}
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const textWithId = threadRootId
? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
: `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
@@ -461,13 +511,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: bodyText,
RawBody: bodyText,
CommandBody: bodyText,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
To: `room:${roomId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
SenderName: senderName,
SenderId: senderId,
@@ -490,6 +541,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
CommandSource: "text" as const,
OriginatingChannel: "matrix" as const,
OriginatingTo: `room:${roomId}`,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
ParentSessionKey: parentSessionKey,
});
await core.channel.session.recordInboundSession({

View File

@@ -30,11 +30,13 @@ async function fetchMatrixMediaBuffer(params: {
// Use the client's download method which handles auth
try {
const result = await params.client.downloadContent(params.mxcUrl);
const buffer = result.data;
const raw = result.data ?? result;
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: Buffer.from(buffer) };
return { buffer, headerType: result.contentType };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
}

View File

@@ -50,7 +50,7 @@ export type MonitorMattermostOpts = {
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
};
type FetchLike = typeof fetch;
type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
type MattermostEventPayload = {
@@ -688,8 +688,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
const mediaPayload = buildMattermostMediaPayload(mediaList);
const inboundHistory =
historyKey && historyLimit > 0
? (channelHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: bodyText,
InboundHistory: inboundHistory,
RawBody: bodyText,
CommandBody: bodyText,
From:

View File

@@ -5,9 +5,9 @@
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",
"dependencies": {
"@lancedb/lancedb": "^0.24.1",
"@lancedb/lancedb": "^0.26.2",
"@sinclair/typebox": "0.34.48",
"openai": "^6.18.0"
"openai": "^6.19.0"
},
"devDependencies": {
"openclaw": "workspace:*"

View File

@@ -454,8 +454,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
}
const inboundHistory =
isRoomish && historyKey && historyLimit > 0
? (conversationHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: rawBody,
InboundHistory: inboundHistory,
RawBody: rawBody,
CommandBody: rawBody,
From: teamsFrom,

View File

@@ -1,16 +1,7 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, isTruthyEnvValue, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
function isTruthyEnvValue(value?: string): boolean {
if (!value) {
return false;
}
return TRUTHY_ENV.has(value.trim().toLowerCase());
}
const debugAccounts = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
console.warn("[nextcloud-talk:accounts]", ...args);

View File

@@ -263,6 +263,7 @@ export async function handleNextcloudTalkInbound(params: {
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,

View File

@@ -371,6 +371,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: messageText,
RawBody: messageText,
CommandBody: messageText,
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,

View File

@@ -69,6 +69,7 @@ async function processTwitchMessage(params: {
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
RawBody: rawBody,
CommandBody: rawBody,
From: `twitch:user:${message.userId}`,

View File

@@ -36,7 +36,7 @@ vi.mock("./utils/twitch.js", () => ({
describe("outbound", () => {
const mockAccount = {
username: "testbot",
token: "oauth:test123",
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
@@ -196,7 +196,14 @@ describe("outbound", () => {
expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("twitch-msg-123");
expect(result.to).toBe("testchannel");
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"Hello Twitch!",
mockConfig,
"default",
true,
console,
);
expect(result.timestamp).toBeGreaterThan(0);
});

View File

@@ -54,7 +54,8 @@ vi.mock("@twurple/auth", () => ({
describe("probeTwitch", () => {
const mockAccount: TwitchAccountConfig = {
username: "testbot",
token: "oauth:test123456789",
accessToken: "oauth:test123456789",
clientId: "test-client-id",
channel: "testchannel",
};
@@ -74,7 +75,7 @@ describe("probeTwitch", () => {
});
it("returns error when token is missing", async () => {
const account = { ...mockAccount, token: "" };
const account = { ...mockAccount, accessToken: "" };
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false);
@@ -84,7 +85,7 @@ describe("probeTwitch", () => {
it("attempts connection regardless of token prefix", async () => {
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
// The actual connection would fail in production with an invalid token
const account = { ...mockAccount, token: "raw_token_no_prefix" };
const account = { ...mockAccount, accessToken: "raw_token_no_prefix" };
const result = await probeTwitch(account, 5000);
// With mock, connection succeeds even without oauth: prefix
@@ -166,7 +167,7 @@ describe("probeTwitch", () => {
it("trims token before validation", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
token: " oauth:test123456789 ",
accessToken: " oauth:test123456789 ",
};
const result = await probeTwitch(account, 5000);

View File

@@ -201,7 +201,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
resolveGroupIntroHint: () =>
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
"WhatsApp IDs: SenderId is the participant JID (group participant id).",
},
mentions: {
stripPatterns: ({ ctx }) => {

Some files were not shown because too many files have changed in this diff Show More