* feat(channels): add GitLab polling channel adapter
Poll GitLab todos via @gitbeaker/rest, dispatch notes through the
existing PollingChannelBase pipeline. Key design points:
- action_prompt_template config drives event filtering and metadata
rendering (unconfigured actions are skipped)
- Per-repo cursor (repo[chatId].last_read) as notes window lower bound,
global lastProcessedAt for todo-level dedup
- mark_done after successful processing; failure skips mark_done for
retry on next poll
- Mention gating delegated to base GroupGate (adapter only sets
isMentioned flag)
- First-contact body fallback for todos with no notes (e.g. mention in
issue description)
* fix(channels/gitlab): persist cursor after each successful todo
Call saveCursor() immediately after advancing lastProcessedAt so that
progress is durable even if the process crashes mid-poll. Also removes
the local watermark variable in favor of direct assignment.
* fix(channels/gitlab): persist cursor on every advancement including skips
* fix(channels/gitlab): address review critical issues
- Remove non-functional proxyAgent (gitbeaker doesn't support it)
- Construct repo_url from host + path (API doesn't return web_url)
- Handle directly_addressed action (falls back to mentioned template)
- First-contact fetches target description instead of using todo.body
- Move todo.project dereference inside try block
- Filter confidential notes
- Update channel-registry.test.ts for gitlab entry
* fix(channels/gitlab): address review suggestions
- Warn on connect if action_prompt_template is not configured
- Guard todo.target.iid before use
- Skip paths now mark_done (best-effort) to clean GitLab UI
- Remove postErrorComment (avoids duplicate comments on retry)
- Fetch only first page of notes (desc, maxPages:1, perPage:100)
instead of paginating entire note history
- Extract fetchRecentNotes for single-page windowed enumeration
* refactor(channels/gitlab): simplify to todo.body dispatch, add description mention support
- Remove notes API fetching; dispatch todo.body directly
- Detect description mentions via target_url anchor (#note_ absence)
- Always fetch target description for %description% metadata
- Remove per-repo cursor; dedup via cursor + mark_done only
- Cursor advances regardless of success/failure (no retry)
- Use zod for cursor validation
- Rename template vars to GitLab terminology:
%project% %project_url% %target_type% %iid% %title% %description% %todo_id%
- Support %% escape for literal percent
* docs(channels): add GitLab adapter documentation
- New user guide: docs/users/features/channels/gitlab.md
- Update _meta.ts navigation
- Update developer adapter matrix and SDK list
* fix(channels/gitlab): use correct Issues.show(issueIid, { projectId }) signature
* chore: regenerate NOTICES.txt for new gitlab channel dependencies
* fix(channels/gitlab): address review suggestions
- Add todo.project null guard (item 2)
- Single-pass regex for %% escape + %var% substitution (item 4)
- sendThreadMessage throws directly on undefined threadId (item 5)
- Dedup fetchDescription with per-poll cache (item 6)
- Remove per-todo saveCursor; base class saves after pollOnce (item 7)
- Add undefined threadId test (item 8)
- Expand confidential notes limitation in docs (item 3)
* test(channels/gitlab): add mention tests, directly_addressed coverage, skip assertions, temp cleanup
- New mention.test.ts: 14 cases for testBotMention/stripBotMention/escapeRegex
- Add directly_addressed fallback test
- Skip tests now assert TodoLists.done + cursor advancement
- afterEach cleans up mkdtempSync temp dirs
* fix(channels/gitlab): address review round 4
- Non-mention actions (assigned, etc.) set forceMentioned=true to bypass GroupGate
- Merge dead note-filter tests into single 'skips todo authored by bot'
- Log fetchDescription errors to stderr instead of silent swallow
- Post error comment on issue/MR when handleInbound fails (best-effort)
* fix(channels/gitlab): always force isMentioned=true, remove regex re-derivation
The action_prompt_template config is already the event filter, and
GitLab has already decided the mention when creating the todo.
Re-deriving isMentioned via regex on todo.body causes permanent
message loss when the regex misses (description mention + fetch
failure, group mentions). Always set forceMentioned=true so
GroupGate never drops a todo that passed the template filter.
* fix(channels/gitlab): propagate fetchDescription errors for description mentions
For note mentions, description is metadata-only — fetch failure is
logged and swallowed. For description mentions, description IS the
message — fetch failure now propagates to the outer catch, which
posts the ⚠️ error comment so the user knows to re-mention.
* perf(channels/gitlab): clean up stale todos, skip unnecessary fetchDescription
- Mark stale todos (updated_at <= cursor) as done on each poll to
prevent perpetual re-fetching of pre-existing pending todos
- Skip fetchDescription for note mentions when template does not
contain %description%, saving one API call per todo
- Update docs: stale todo cleanup, error comment on failure
* docs(channels/gitlab): clarify requireMention is bypassed, template is the real filter
* Apply suggestions from code review
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
* fix(channels/gitlab): use todo ID cursor instead of timestamp to eliminate equal-timestamp loss
Timestamp-based cursors (second granularity) could silently destroy
todos sharing the same updated_at as the cursor boundary. Switch to
monotonically increasing todo IDs which are unique and collision-free.
Add initialized flag to preserve first-start drain semantics: pre-existing
pending todos are marked done without dispatch on the first poll cycle.
* fix(channels/gitlab): harden first-poll drain, add ordering tests, fix lockfile
- Replace Math.max(...spread) with reduce to avoid RangeError on large
backlogs (~100k+ todos). Move initialized=true after the drain work so
any throw retries the drain instead of falling through to dispatch.
- Add unit tests: identical-timestamp delivery and id-order-when-updated_at-disagrees
(kills M2 sort mutant).
- Align lockfile: file:../base → ^0.21.0 for channel-base dep.
* fix(channels/gitlab): include dot in mention lookahead for GitLab usernames
GitLab usernames may contain dots (e.g. bot.name). The lookahead
character class inherited from GitHub omitted '.', causing @bot.name
to match as @bot. Add '.' to the negated class.
* docs(channels/gitlab): align docs with ID cursor and drain semantics
- Add first-poll drain as step 2 in How It Works
- Clarify GroupGate always passes (isMentioned forced true)
- Document initialized flag in Known Limitations
* Apply suggestions from code review
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
* fix(channels/gitlab): align package version and channel-base dependency to 0.21.1
Bump version from 0.21.0 to 0.21.1 to match other channel packages after
upstream merge. Pin @qwen-code/channel-base to exact 0.21.1 instead of
^0.21.0, matching the convention used by other published channels.
* fix(channels/gitlab): regenerate lockfile to match package.json versions
Manually add only gitlab-related lockfile entries (workspace, @gitbeaker
packages, transitive deps, channel-gitlab link) without unrelated npm
normalization churn.
* test(channels/gitlab): add regression tests for first-poll drain hardening
Two tests that kill the M1 (Math.max spread RangeError) and M2 (flag
ordering) mutants which survived the original 46-test suite:
- 150k todo drain verifies reduce() handles large backlogs without
RangeError and without dispatching
- Drain throw verifies initialized stays false so the next poll retries
the drain instead of falling through to dispatch
Test file duration: ~40ms → ~170ms.
* docs(channels/gitlab): clarify groupPolicy must be "open" and add runtime warning
The default groupPolicy "disabled" silently drops all mentions — todos are
marked done and cursor advances, but no dispatch occurs. Fix misleading docs
that said "GroupGate always passes" (only true at groupPolicy: "open") and
add a connect()-time warning when groupPolicy is not "open".
* fix(channels/gitlab): correct xcase integrity hash in lockfile
The manually added xcase entry had a typo in the sha512 hash (ys → ks),
causing npm ci EINTEGRITY failures in CI.
* fix(channels/gitlab): correct requester-utils integrity hash in lockfile
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels/gitlab): allow groupPolicy "allowlist" in warning and docs
The groupPolicy warning and docs incorrectly stated that groupPolicy
must be "open". In reality "allowlist" with the project listed also
works because isMentioned is forced true and GroupGate only requires
the group to be listed. Also fix the inaccurate "no error is logged"
claim — ChannelBase logs preflight rejected reason=group_disabled.
Fixes R5-🟡3 from PR #7862 review.
---------
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* feat(channels): dispatch GitHub notifications by reason
Route each GitHub notification by notification.reason into one of five
lanes, instead of dispatching every new comment regardless of trigger:
- mention: only dispatch comments that actually @ the bot (noise reduction)
- review_requested (PR): fetch PR meta via pulls.get and dispatch a
review-specific prompt, even with no new comments
- assign: fetch issue meta and dispatch a triage-specific prompt
- author/comment: aggregate the window's new comments into one check-and-
respond prompt
- other reasons: generic fallback (current behavior)
Add cursor dedup via dispatchedComments (by comment node_id) and
dispatchedNotifications (by notification id), surviving a
markNotificationsAsRead failure that leaves the cursor un-advanced.
Closes#7807
* fix(channels): mark review_requested/assign envelopes as mentioned
GroupGate defaults to requireMention: true, which silently drops
isMentioned:false envelopes as 'mention_required'. The review_requested
and assign lanes are explicit directed triggers — the bot was asked to
review or assigned — equivalent to a mention, so set isMentioned: true
so they pass the gate instead of being inert on the documented default
config.
Addresses review Critical on #7826.
* fix(channels): resolve github routing review comments
* fix(channels): dedupe github meta lane comments
* fix(channels): conditional assign framing for PR threads
The assign route already detected PR threads to use pulls.get, but the
trigger framing text always read 'assigned to this issue' even for PRs.
Make it conditional so PR assignments read 'assigned to this pull request'.
* fix(channels): dedup meta lane dispatch inputs
* fix(channels): simplify GitHub reason dispatch
* fix(channels): respect mention gate for github aggregate lane
* fix(channels): truncate aggregate comment bodies by code points
Match the code-point-aware truncation already used for meta-lane bodies
so a supplementary-plane emoji at the MAX_COMMENT_CHARS boundary is not
split into a lone surrogate.
* fix(channels): harden GitHub dispatch failures, event window, and framing (#7826)
- Classify deleted/transferred subjects (404/410) as terminal so a single
dead notification is logged and skipped instead of wedging the batch's
mark-read and cursor advance every poll.
- Widen the review_requested/assign event search to the newest ~100 events
by merging the preceding page when the last page is partial, instead of
inspecting only the last page (which can hold a single event).
- Move the aggregate lane's untrusted-data warning to the head of the prompt
text so it precedes the comment text it describes (metadata is appended
after text by ChannelBase).
- Add regression tests: permanent-failure two-poll advance, terminal 404
no-retry, multi-page event search, prompt caps, and the no-actor guard.
* fix(channels): drop lastReadAt filter in findMetaTrigger, add review coverage (#7826)
* fix(github): keep aggregate and meta windows bounded
* fix(channels): apply windowSince lower bound in findMetaTrigger (#7826)
* fix(channels): bound retry wedge, compute aggregate isMentioned, fix pairing pre-filter (#7826)
* fix(github): record dispatch before handler
* fix(github): persist skipped notifications
* fix(github): close dispatch retry loss cases
* test(github): cover cursor trim and meta floor validation
* fix(github): simplify notification reason dispatch
* fix(github): preserve batched dispatch comments
* fix(github): restore direct event dedup
* fix(github): preserve directed mention context
* fix(github): keep review fixes scoped
* fix(github): preserve delayed direct triggers
---------
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code <qwen-code@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
* fix(channels): use username as senderId in GitHub adapter to fix allowlist gate
isAuthorizedForSharedSessionTarget compares config.allowedUsers (logins)
against envelope.senderId — but senderId was a numeric ID resolved via
getByUsername, so every allowlisted user was rejected from /who, /clear,
/status, /loop, and channel memory commands.
Fix by using user.login as senderId throughout, assuming GitHub users
don't change usernames. This also removes the getByUsername resolution
step that made connect() non-idempotent on daemon reconnect.
- Remove botUserId field; bot self-filter uses botUsername
- Remove allowedUsers login-to-ID resolution in connect()
- Pass config.allowedUsers logins directly to gate
- senderId in envelopes uses user.login
* fix(channels): normalize allowlist/senderId to lowercase for case-insensitive matching
GitHub logins are case-insensitive but Set.has/Array.includes are not.
Without normalization, allowedUsers: ['Alice'] silently rejects a
commenter whose canonical login is 'alice' — a regression from the old
getByUsername round-trip which normalized casing implicitly.
- Normalize config.allowedUsers and gate to lowercase in connect()
- Lowercase senderId at both envelope assignment sites
- Remove dead != null guard in bot self-comment filter
- Add case-insensitive gate test and connect idempotency test
- Add senderId/allowedUsers comparability guard to dispatch test
- Document username-based allowlist rename risk in security section
* feat(channels): add GitHub polling adapter with notification-as-wakeup architecture
Introduce a GitHub channel adapter that monitors notifications and
responds to @mentions on issues/PRs by posting comments. Uses
last_read_at as a per-thread watermark for comment enumeration,
replacing the unreliable latest_comment_url approach.
Foundation changes to ChannelBase:
- sendThreadMessage for thread-targeted delivery (IM adapters unchanged)
- Envelope.metadata appended to prompt after command parsing
- chat_thread session scope (channel:chatId:threadId) prevents
cross-repo session collision
- polling-helpers: testBotMention/stripBotMention (separate detection
from stripping, no whitespace collapsing), cursor persistence,
abortableSleep
GitHub adapter design:
- Notifications as wake-up signals only (unread filtering)
- listComments enumeration with last_read_at watermark
- Bot self-comment filtering, case-insensitive mention regex
- In-memory recentlyProcessed set for mark-read failure dedup
- First-contact: new issue body @bot triggers processing
- Error comment + cursor advance on handleInbound failure
- pollInterval minimum 60s, exponential backoff 2s-30s
* refactor(channels): extract PollingChannelBase from polling-helpers
Replace the loose polling-helpers module with a PollingChannelBase<Cursor>
abstract class that encapsulates the poll loop, cursor persistence (JSON,
atomic write), exponential backoff, and start/stop lifecycle. Subclasses
implement only pollOnce() and createInitialCursor().
- Delete polling-helpers.ts (cursor fns + abortableSleep moved into base)
- Move mention utilities (testBotMention/stripBotMention) to github pkg
- GithubAdapter now extends PollingChannelBase<{ lastProcessedAt }>
* fix(channels): remove Gitea/GitLab mention from sendThreadMessage JSDoc
* fix(channels): match /pulls/N in notification subject URL
GitHub PR notifications use /repos/{owner}/{repo}/pulls/{N} in
subject.url, not /issues/{N}. The regex only matched /issues/,
causing PR notifications to be skipped and marked read.
Also sets threadId to 'pr:N' for PRs (was always 'issue:N').
* test(channels): add PR body first-contact unit test
Verify that PR notifications with @mention in the body (not a comment)
correctly trigger the first-contact path: extractFromSubjectUrl matches
/pulls/N, listComments returns empty, tryFirstContactBody fetches the
PR body and dispatches to handleInbound with threadId 'pr:N'.
* feat(channels): read pollInterval from channel config in PollingChannelBase
Move pollInterval config reading from GithubAdapter to the base class.
The user's configured pollInterval in settings.json is now respected
directly without a minimum enforcement. Defaults to 60000ms when not
configured.
* fix(channels): prepend metadata before prompt text
Agent sees issue/PR context (type, title, URL) before the user's
request, improving comprehension. Metadata is still appended after
slash-command parsing so commands are not affected.
* refactor(channels): route all ChannelBase delivery through sendThreadMessage
Replace all internal sendMessage calls with sendThreadMessage, passing
envelope.threadId (or target.threadId / undefined) so polling adapters
can deliver to the correct thread. IM adapters are unaffected — the
default sendThreadMessage falls through to sendMessage.
* docs(channels): document sendThreadMessage delivery architecture
* fix(channels): address review findings
- Cap recentlyProcessed Set at 10k entries to prevent unbounded growth
- Validate cursor JSON shape (non-null object) in loadCursorFromDisk
- sendThreadMessage falls through to sendMessage when threadId is
undefined instead of silently dropping
- Remove duplicate pollInterval from GithubConfig (now in ChannelConfig)
- Fix chat_thread routing key trailing colon when threadId is undefined
* docs(channels): fix metadata JSDoc — prepended, not appended
* fix(channels): use recentlyProcessed dedup for first-contact body
Replace the fragile createdAt-vs-cursor check in tryFirstContactBody
with the recentlyProcessed set. The cursor advances globally based on
notification updated_at — when a different notification with a later
updated_at is processed first, the cursor can advance past the issue's
created_at, causing the first-contact check to incorrectly skip the
issue body (forget reply bug, found in E2E TC-2b).
* refactor(channels): two-layer dedup for GitHub adapter
Layer 1: global cursor filters notifications by updated_at (sorted
ascending, old first). Layer 2: server-side last_read_at filters
comments by created_at (sorted ascending).
- Delete recentlyProcessed Set (no longer needed)
- Sort notifications by updated_at ascending before processing
- Sort comments by created_at ascending before processing
- Pass latest comment created_at to markThreadAsRead as last_read_at
* fix(channels): address review findings on GitHub adapter
Blockers:
- sessionScope: add defaultSessionScope to ChannelPlugin, apply in
parseChannelConfig so router and adapter agree on 'chat_thread'
- channel-registry.test.ts: add 'github' to expected type list
Should-fix:
- Replace per-thread markThreadAsRead (PATCH) with bulk
markNotificationsAsRead (PUT /notifications + last_read_at).
API errors stop the batch without marking failed notifications
read; handleInbound errors still advance (error comment posted).
- connect() throws on bot identity failure instead of failing open
- metadata appended after promptText (inside sender attribution)
- isSharedSessionTarget includes 'chat_thread' scope
Nits:
- startPollLoop re-entrancy guard
- clean-package-build-artifacts.js includes github
- index.ts re-exports GithubChannel
* fix(channels): use max updated_at of all fetched notifications as last_read_at
Prevents re-fetching the same notifications in the next poll cycle.
The bulk PUT /notifications marks all fetched notifications as read
up to the max updated_at, regardless of per-notification success.
* fix(channels): address review round 2 findings
- #12: loadCursorFromDisk rejects arrays
- #13: pollInterval validates positive finite number
- #19: first-contact gate uses dispatchedMention flag (not newComments.length)
- #25: stripBotMention no longer trims (preserves indentation)
- #27: remove adapter-level requireMention, unify on GroupGate
- #31: add chat_thread SessionRouter routing key tests
- #33: clear metadata on collect-mode synthetic envelope
- #35: fix PollingChannelBase.test import path
- #36: add @octokit/rest to 15-channel-adapters.md dependencies
* docs(channels): document known limitations for GitHub adapter
- First start skips existing unread notifications (cursor = now)
- Requires classic PAT (fine-grained PATs lack notifications API)
- PR review comments not enumerated (issue comments only)
* fix(channels): address review round 3 findings
- #9: buildMetadata derives web URL from baseUrl (GHE support)
- #12: sendThreadMessage throws on invalid threadId format
- #19: mention lookbehind matches cc:@bot and "@bot" patterns
- #23: cursor file name uses sha256 hash to prevent collision
- #26: test verifies cursor persistence to disk
- #31: postErrorComment double-failure logs to stderr
- #45: tests use mkdtempSync isolation instead of real QWEN_HOME
* fix(channels): pass threadId through pairing flow + sendResponseMessage test
- #13+16: onPairingRequired receives envelope.threadId and passes it
to sendThreadMessage, so pairing codes are delivered on threaded
channels (GitHub) instead of throwing
- #6: add test verifying sendResponseMessage resolves threadId from
router.getTarget and passes it to sendThreadMessage
* fix(channels): pass proxy to Octokit for daemon-worker environments
- #44: read this.proxy from ChannelBaseOptions and pass
HttpsProxyAgent to Octokit request.agent, matching the
Telegram adapter pattern
* fix(channels): address review findings — immutable senderId, comment time window, validateCursor, retry wrapper
- senderId uses immutable user.id; allowedUsers resolved to IDs at connect
- Comment filter upper bound: updated_at <= maxUpdatedAt (batch window)
- Per-notification errors use continue (best-effort), not break
- validateCursor() virtual hook for subclass cursor shape validation
- sendThreadMessage/postErrorComment wrapped in githubApi() retry
- webOrigin handles default api.github.com → github.com
- Docs: classic PAT only, markNotificationsAsRead, dedup claims removed
- Tests: threadId priority, metadata consumption, defaultSessionScope,
QWEN_HOME isolation, persistent mock rejection
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): mark notifications read before processing to prevent duplicate replies
Bot's own replies bump notification updated_at past the pre-captured
maxUpdatedAt, so markNotificationsAsRead(maxUpdatedAt) failed to mark
them read — the next poll re-fetched the same comments and replied
again.
Move markNotificationsAsRead + cursor advance before the processing
loop (best-effort delivery). This is safe because bot's own comments
do not flip notifications back to unread. Update docs to reflect the
new poll cycle order and best-effort semantics.
* fix(channels): update sender gate after allowedUser ID resolution and harden tests
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): cursor-based comment window to prevent duplicate replies
PUT /notifications is async (202) with a last_read_at cutoff — the
bot's reply bumps updated_at past the cutoff before the server
processes the mark, so the notification is never marked read and gets
re-fetched on the next poll, causing duplicate replies.
Use the cursor value before advancement as an exclusive lower bound
for the comment enumeration window: (windowSince, maxUpdatedAt].
Comments already eligible in a previous poll are excluded regardless
of whether the mark succeeded. Zero new persistent state.
* fix(channels): cursor-based comment window to prevent duplicate replies
PUT /notifications is async (202) with a last_read_at cutoff — the
bot's reply bumps updated_at past the cutoff before the server
processes the mark, so the notification is never marked read and gets
re-fetched on the next poll, causing duplicate replies.
Use the cursor value before advancement as an exclusive lower bound
for the comment enumeration window, with per-notification last_read_at
as the preferred lower bound when available (server-side per-thread
watermark). Comments already eligible in a previous poll are excluded
regardless of whether the mark succeeded. Zero new persistent state.
* fix(channels): address review findings — null guard, cursor validation, metadata dedup, abortable sleep, docs
- Guard against null notification.subject.url in pollOnce
- Validate lastProcessedAt is a parseable date in validateCursor
- Add metadata: undefined to second collect-mode drain path
- Refactor abortableSleep as protected method on PollingChannelBase
- Fix docs: requireMention is nested under groups.*
- Add tests: chat_thread shared session, dispatchedBodies eviction,
cursor enumeration window, last_read_at in mention tests
* docs(channels): sync docs with implementation — cursor shape, error handling, GitHub adapter tables, first-contact
- Design doc: update Cursor to { lastProcessedAt, dispatchedBodies? }, add
validateCursor date check, abortableSleep protected method, break-on-error
semantics, subject.url null guard
- Developer docs: add GitHub to adapter table and adapter matrix
- User guide: add first-contact step to How It Works, clarify mark-before-process
* fix(channels): address review round 2 — error dedup, abortable retry, backoff reset, window test
- Record dispatchedBody on first-contact handleInbound failure to prevent
duplicate error comments when mark-read async hasn't taken effect
- Use abortableSleep instead of raw setTimeout in githubApi retry so
disconnect() can interrupt rate-limit cooldowns
- Reset consecutiveErrors in startPollLoop so stop/restart cycles don't
inherit stale elevated backoff
- Add test for cursor window client-side lower-bound exclusion filter
* fix(channels): address review round 3 — cursor validation, error dedup, sender gate, bot-self body
- validateCursor: normalize falsy non-array dispatchedBodies (false/0/""/null)
to [] instead of passing them through to .includes() which throws TypeError
- Set dispatchedMention after postErrorComment to prevent first-contact from
posting a duplicate error comment on the same thread
- Only set dispatchedMention when the sender passes the sender gate, so a
disallowed commenter's mention no longer suppresses a valid first-contact
body from an allowed issue author
- Skip bot-authored issue bodies in tryFirstContactBody to prevent
self-response loops under open sender policy
* fix(channels): address review suggestions — test coverage, cursor filename, assertion precision
- Pairing flow: add threadId pass-through regression test
- pollInterval: add table-driven edge cases (0, -1, NaN, Infinity, string)
- Add null-URL notification followed by valid notification batch test
- Fix comment window test to assert paginate call 3 (listComments) not call 2
- Truncate cursor filename encoded prefix to 200 chars (filesystem 255 limit)
- Assert mark-read uses batch maxUpdatedAt, not just { read: true }
- Assert real GitHub plugin declares defaultSessionScope chat_thread
- Add invocationCallOrder assertion for mark-before-process ordering
* fix(channels): address review round 4 — allowedUsers throw on resolve failure, crash table fix, mark-read failure test
* fix(channels): address review round 5 — created_at filter, retry-after NaN guard, retry/sendThreadMessage tests, docs fixes
* fix(channels): address ci-bot review 4778587403 — reconnect idempotency, github type enumerations, retry/webOrigin tests
* chore(channels): align channel-github version to 0.21.0 after upstream merge
* chore(channels): update package-lock.json for channel-github 0.21.0
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
---------
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: OrbitZore <orbitzore@users.noreply.github.com>
* fix(channels): scope pairing and allowlist state by workspace
PairingStore keyed its on-disk files by channel name alone, under the
global ~/.qwen/channels/ directory. Two workspace-scoped channel
configurations using the same channel name therefore shared pairing
requests and allowlist entries: a sender approved for workspace A was
implicitly approved for workspace B — an authorization-boundary
violation in multi-workspace daemon deployments.
PairingStore now takes the channel's workspace cwd and stores state
under channels/<basename>-<sha256[:12]>/, ChannelBase passes
config.cwd, and the pairing CLI commands gain a --cwd option
(defaulting to the current directory) so list/approve address the same
workspace-scoped store the channel worker uses.
Migration is a conservative one-time grandfather: on first scoped use,
existing legacy global files are COPIED into the scope (so
already-approved senders stay approved and other workspaces can
grandfather the same baseline later), after which the stores diverge —
no ongoing cross-workspace sharing, and legacy content can never
overwrite scoped state.
Fixes#7017
* fix(channels): canonicalize scope identity and gate migration per directory
Address the review findings on #7065:
1. Scope identity now follows the repo's workspace-canonicalization
contract: getWorkspaceScopeDirName realpaths the resolved path (with
the same ENOENT fallback as acp-bridge's canonicalizeWorkspace, which
channel-base mirrors locally to stay dependency-free). Symlinked and
platform-case-variant spellings of one directory — macOS /tmp/ws vs
/private/tmp/ws — now address the same store from a daemon worker
and from the CLI's --cwd.
2. Legacy grandfathering is gated at the scope-directory level instead
of per file: once the scoped directory exists, legacy files are
never consulted again. A per-file gate let a legacy allowlist
silently re-approve senders an operator had revoked by deleting the
scoped allowlist file, and let an in-use scope absorb a legacy file
that appeared later. The README now spells out that revocation means
removing entries, not deleting files.
3. The empty `pairing list` output names the workspace scope and points
at --cwd, mirroring the approve error, since a scope mismatch
surfaces there first.
Four new regression tests (symlink collapse, ENOENT fallback, no
resurrection after revoke, no late-legacy absorption) fail on the
previous commit and pass here.
Refs #7017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(channels): state the broad realpath fallback is intentional; make the ENOENT scope assertion meaningful
Two round-2 review notes on #7065:
- canonicalizeWorkspacePath's docblock claimed to match acp-bridge's
ENOENT-only fallback while the catch swallows every realpath error.
Keep the broad catch — pairing storage is best-effort and a transient
FS error must not stop the channel from starting — and document that
divergence explicitly instead.
- The ENOENT-fallback test's second assertion compared a scope name to
itself. It now compares against the scope computed from the resolved
spelling, pinning that the realpath step degrades to a no-op for
nonexistent paths.
Refs #7017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(channels): always close the migration gate, normalize nonexistent-path scopes, copy atomically
Address the automated round-2 inline findings on #7065:
- The migration gate is now closed on the very first construction even
when no legacy files existed: the scope directory itself is the
"migration decided" marker. Previously a workspace that first ran on
new code before any legacy state existed left the gate open, and a
legacy allowlist written later by an older version still running
concurrently would have been absorbed.
- resolvePath now runs every input through path.resolve, so
trailing-separator and dot-dot spellings of a path that does not
exist on disk (where the realpath step cannot help) canonicalize to
the same scope instead of three different ones.
- Legacy files are copied via temp file + atomic rename, so a crash
mid-copy cannot leave a truncated scoped file behind the now-closed
gate, and a concurrent first construction cannot observe a
half-written allowlist.
Adds three regression tests (late-legacy not absorbed after empty
first startup, nonexistent-path spelling collapse, unreadable legacy
file keeps the constructor best-effort); the first two fail on the
previous commit.
Refs #7017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(channels): encode channel names in scoped paths, gate migration per channel, wire tests into CI
Three independently reproduced problems in the workspace-scoping change,
found in external review of d8c155ab3:
- Channel names come from unrestricted config keys and were joined into
the scoped path verbatim, so a name like `../support` climbed out of
the scope directory and landed every workspace on one shared file at
the channels root — silently undoing the isolation this PR exists to
establish. File names now URI-encode the channel name (mirroring
GroupHistoryStore), common names encode to themselves, and the legacy
source path is containment-checked as defense in depth.
- The directory-level migration gate let only the FIRST channel of a
workspace migrate: one process starts several channels in turn, and
once the first construction created the scope directory, every other
channel's legacy state was skipped forever. The gate is now a
per-channel `<channel>.migrated` sentinel inside the scope directory,
written even when there was nothing to copy.
- A single unreadable legacy file aborted the whole migration loop and
the gate still closed, so the other (valid) file was never migrated
and never retried. Files are now copied independently, best-effort,
via uniquely-named temp files + atomic rename, and scoped files are
never overwritten.
Also adds the missing test/test:ci scripts to channels/base (matching
its sibling packages), so the package's 784 tests actually run in CI's
`npm run test:ci --workspaces --if-present` sweep.
Four new regression tests (traversal-name isolation, multi-channel
migration, late-channel migration, unreadable-file independence) all
fail on d8c155ab3 and pass here.
Refs #7017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(channels): read legacy files under the raw name, retry partial migrations, log failures
Round-3 review findings on #7065:
- Legacy sources are read under the RAW channel name again: pre-scoping
code wrote them unencoded, so looking them up under the encoded name
made any channel whose name changes under encoding (e.g. "my
channel") skip its legacy state and permanently lose approved
senders behind the sentinel. Encoded names remain in use for the
scoped destinations; the containment check keeps traversal-style raw
names from reading outside the channels root.
- The sentinel is only written when every present legacy file was
copied (or already existed). A partial failure (ENOSPC, transient
I/O) previously closed the gate with incomplete state; now the next
construction retries the failed file, and per-file stderr warnings
are emitted so operators can see why senders are missing instead of
instrumenting the constructor.
- The symlink test cleans up with unlinkSync — rmSync throws EISDIR
for a symlink to a directory on macOS.
- The pairing CLI gains tests covering --cwd scoping end to end (list
isolation, empty-scope hint, approve scoping, cross-workspace code
rejection), plus an explicit return after the mocked-in-tests
process.exit(1).
The raw-name and partial-retry regression tests fail on 954e76af4.
Refs #7017
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(channels): add dmPolicy config to disable private/DM messages
Add DmGate class mirroring GroupGate to gate DM/private messages in
channel adapters. Operators can now set dmPolicy: 'disabled' in their
channel config to silently drop all DM messages while keeping group
messages active.
Closes#6392
* fix(channels): address review feedback for dmPolicy
- Add dmPolicy: 'open' to all test config factories (8 files) to
maintain type correctness with required ChannelConfig field
- Add integration tests in ChannelBase.test.ts:
- preflightInbound: DM dropped + group passes when dmPolicy=disabled
- isStoredLoopTargetAuthorized: DM loop job disabled + group passes
- Add dmPolicy assertions in config-utils.test.ts (default + explicit)
- Keep dmPolicy as required field (not optional) for strict parity
with groupPolicy
The WeCom intelligent robot channel (added in #6436) has its own guide and _meta.ts entry, but the channels overview was never updated. Add WeCom to the platform list, quick-start guide links, the `type` option and `token` exclusion note, new `botId`/`secret` option rows, the media-support note, and the slash-command channel enumeration.
Co-authored-by: Claude <noreply@anthropic.com>
* feat(channels): add natural channel memory intents
* fix(channels): add explicit guard and exhaustiveness check for clear_confirm intent
The clear_confirm path was handled as implicit fall-through at the bottom
of handleChannelMemoryIntent. If a new intent kind were added to the
ChannelMemoryIntent union, it would silently execute clearChannelMemory
without user confirmation — a data-loss risk.
Add explicit if (intent.kind === 'clear_confirm') guard and a
const _exhaustive: never assertion so TypeScript flags any unhandled
kinds at compile time.
* fix(channels): close session leak in classifier and fix regex separator
- BridgeChannelMemoryIntentClassifier now wraps prompt() in try/finally
to always call cancelSession(), preventing daemon session leaks on
every classifier invocation. Cleanup errors are caught so they cannot
mask a successful classification result.
- Add missing optional punctuation separator to the 以后记住 regex
pattern for consistency with other Chinese remember patterns.
* fix(channels): enforce pending clear state for channel memory confirmation
The clear_confirm intent executed clearChannelMemory directly without
verifying a prior clear_request was issued for the same chat. Any
authorized user could clear any chat's memory by sending the confirmation
phrase standalone, bypassing the two-step flow.
Add a per-target pending clear map (chatId + threadId, 60s TTL) that
is set during clear_request and verified+consumed during clear_confirm.
Standalone confirmation phrases now get rejected with a prompt to
issue the clear request first.
* fix(channels): include senderId in pendingClears key to prevent cross-user confirmation
User A could initiate clear_request in a group chat and User B could
confirm it, since the pending key only included chatId+threadId.
Add senderId to the key so only the user who initiated the clear
can confirm it.
* fix(channels): harden memory intent review fixes
* fix(channels): cover memory clear sender guard
* fix(channels): block group memory mutations
* fix(channels): avoid ambiguous memory saves
* test(channels): cover memory classifier cleanup
* test(channels): cover memory clear expiry
* fix(channels): restore channel memory slash aliases
* test(channels): cover memory intent edge cases
* docs: fix skill invocation syntax and include Feishu in channel lists
* docs: add Feishu column to channel media-handling table
Address review feedback on PR #6320: after adding Feishu to the prose
channel lists, the 'Platform differences' media-handling table still
omitted it. Add a Feishu column (images/files via the authenticated Open
API resources endpoint, 50MB limit; rich-text 'post' captions), verified
against packages/channels/feishu/src/media.ts and FeishuAdapter.ts. Add a
note that QQ Bot ignores incoming media (per QQChannel.ts) so it has no
row.
* docs: clarify Feishu post messages drop embedded images
The Platform differences table claimed Feishu rich-text (post) messages
carry 'mixed text + images', but FeishuAdapter's post parser only
extracts text/a/at nodes and silently drops img nodes (both the live
handler and the history-backfill path). Correct the Captions cell to
say text is extracted and embedded images are ignored.
* docs: mark clientId/clientSecret as required for Feishu too
Feishu declares requiredConfigFields: ['clientId', 'clientSecret']
(packages/channels/feishu/src/index.ts), same as DingTalk, but the
channel options table listed both fields as DingTalk-only. Update the
Required column and descriptions to cover Feishu (App ID / App Secret).
* docs: note token is not needed for Feishu in channel config table
* docs: note 50MB limit on Feishu image downloads
Feishu images and files both flow through the same downloadMedia() in
packages/channels/feishu/src/media.ts, which enforces a single
MAX_DOWNLOAD_BYTES = 50MB cap. Add the (50MB limit) note to the Images
cell for consistency with the Files cell.
* docs: clarify /skills panel-vs-run behavior per review feedback
- skills.md: add a migration Note that /skills <name> now opens the
Skills panel and ignores trailing args; use /<skill-name> to run.
- commands.md: list the /skills Usage cell as /skills, /<skill-name>
for consistency with the rest of the table.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 易良 <1204183885@qq.com>
* feat(channels): add group history backfill
* fix(channels): harden group history backfill
* fix(channels): address group history review gaps
* fix(channels): apply wildcard group history limit
* feat(channel): add QQ Bot channel adapter
Add @qwen-code/channel-qqbot package implementing QQ Bot WebSocket
Gateway connection via the official QQ Bot API.
Supports:
- WebSocket Gateway (HELLO/IDENTIFY/HEARTBEAT/DISPATCH/RECONNECT)
- C2C single chat (C2C_MESSAGE_CREATE)
- Group @mention (GROUP_AT_MESSAGE_CREATE) — code path exists, unverified
- Streaming output via msg_id + msg_seq multi-block sending
- Auto-reconnect with exponential backoff
- Sandbox environment toggle
TODO (technical debt acknowledged):
- Group chat not verified end-to-end
- Single-file architecture (should split into gateway/send/auth modules
like weixin channel)
- No tests (weixin has send.test.ts + media.test.ts)
- No typing indicator (onPromptStart/onPromptEnd not yet implemented)
- No channel instructions injection in connect()
- No structured error types
Closes#5201
* feat(qqbot): add QR login, group chat support with typed events
- Add QR code login via @tencent-connect/qqbot-connector with credential persistence
- Add Intent constants for C2C (1<<12) and GROUP_AT_MESSAGE (1<<25)
- Use QQGroupMessageEvent type in handleGroup instead of cast
- Remove resolved TODO comments for group chat verification
- Add msg_seq to send error log for debugging
* fix(qqbot): address PR review — lint errors, token refresh, security
- Use bracket notation for Record<string, unknown> to fix TS4111 lint errors
- Add chmodSync(credsFile, 0o600) for credential file permissions
- Implement token refresh at 80% TTL with expires_in tracking
- Fix RECONNECT opcode: use code 4000 + serverRequestedReconnect flag
- Fix connect() Promise: reject on close before READY via connectReject
- Log empty-token case in sendMessage, drain response body on error
- Clear chatTypeMap/replyMsgId/msgSeqMap in disconnect()
- Capture msgId at send-time to avoid race on replyMsgId
- Switch channel-registry.ts to Promise.allSettled (isolated channel failures)
- Add chatId validation (isValidChatId) to prevent SSRF
* fix(qqbot): add qqbot to build order, fix ESLint default-case
- Add packages/channels/qqbot to scripts/build.js buildOrder
(CLI imports @qwen-code/channel-qqbot but it wasn't being built)
- Add default case to handleGatewayMessage switch
* feat(qqbot): prepend sender name in group messages for shared context
When sessionScope is set to 'thread', all group members share one
session. Prepending [senderName] helps the agent distinguish who
said what in the shared context.
* feat(qqbot): cross-server context continuation via SessionRouter persistence
- Persist SessionRouter mappings to disk via sessionsPath, surviving daemon restarts
- Persist QQ routing state (chatTypeMap, replyMsgId, msgSeqMap) to {name}-state.json
- Backup/restore global sessions.json on disconnect/connect to survive start.ts cleanup
- fixRestoredSessions() workaround for ACP LoadSessionResponse missing sessionId
- READY handler delays resolve() until restoreSessions() completes, preventing race
* feat(qqbot): add Session Resume + reconnect retry resilience
- Support WS session resume (RESUME opcode 6) on reconnect,
falling back to full IDENTIFY when session is invalid
- Add reconnectWithRetry() loop: retries gateway fetch up to 5x
with exponential backoff, then schedules 60s fallback retry
(fixes silent death after GW HTTP 500)
- connect() now retries up to 3 times on initial failure
- Bump maxReconnectAttempts from 10 to 20
- Refresh token before each reconnect attempt
* fix(qqbot): address review feedback from wenshao
- fixRestoredSessions: use entry.target directly instead of tt.get(undefined)
(fixes first restored session routing to wrong conversation when 2+ sessions)
- scheduleTokenRefresh: retry in 60s on token refresh failure, not just log
- sendMessage: move saveQQState() after chunk loop, avoid redundant disk I/O
- handleGroup: drop message when group_openid is missing instead of falling
back to author.id (which would cause 404 on group message send)
* fix(qqbot): address 3rd review from doudouOUC (12 issues)
- QWEN_HOME: use getGlobalQwenDir() instead of homedir()
- name sanitization: prevent path traversal in file paths
- fetch timeouts: AbortSignal.timeout(15s) on all 3 fetch calls
- TOCTOU: writeFileSync with {mode: 0o600} instead of chmodSync after
- msg_seq gaps: only increment seq on send success, break on failure
- message dedup: seenMessages Map with 5min TTL cleanup timer
- disconnect: set disposed flag + flushQQState sync + clear timers
- heartbeat ACK: track lastHeartbeatAck, force close on 2x interval timeout
- reconnect exhaustion: FATAL log when max attempts reached post-connect
- debounced saveQQState: 500ms debounce, flush on disconnect
- handleGroup: skip [senderName] prefix for slash commands, log for audit
- disposed guard: connectGateway checks disposed before creating WS
* fix(qqbot): robustness round — RESUMED, token expiry, SSRF, disposed, typing stubs
- Handle RESUMED event on RESUME success (start heartbeat, restore sessions)
- Check token expiry before sendMessage, refresh if expired
- Tighten isValidChatId regex (remove . and /) to close path traversal
- Reset disposed flag in connect() for reusability
- Add onPromptStart/onPromptEnd stubs (QQ Bot has no typing API)
- Add robustness comments for splitText surrogate pairs, restoreQQState
corruption, and senderId identity fragmentation across contexts
* refactor(qqbot): split into modules — api, accounts, login
Extract HTTP calls, credential I/O, and QR login into separate files
matching the weixin channel's architecture:
- api.ts: fetchAccessToken, fetchGatewayUrl, getApiBase, sendQQMessage
- accounts.ts: getCredsFilePath, loadCredentials, saveCredentials
- login.ts: qrCodeLogin (qrConnect wrapper)
QQChannel.ts drops inline fetch/credential/qrConnect logic and imports
from the new modules. Net -41 lines in the adapter.
* feat(qqbot): markdown message support (msg_type: 2)
Detect markdown syntax in AI responses and send as msg_type=2
with markdown.content field instead of plain-text msg_type=0.
Detection covers headers, code blocks, bold, italic, strikethrough,
inline code, links, and lists via a single regex.
* fix(qqbot): defensive patches from complete review
- reconnectWithRetry: guard against disposed channel to prevent infinite loop
- handleGroup: broaden @mention regex to match both legacy <@!id> and V2 <@openid>
- handleGroup: set isReplyToBot=true (every group msg is an @mention)
- fixRestoredSessions: document fragile private-field access
- saveCredentials: correct TOCTOU claim in comment
- hasMarkdownSyntax: document false-positive trade-off
* fix(qqbot): guard against empty content in C2C and group handlers
- handleC2C: return early when event.content is null/empty (image/sticker msgs)
- handleGroup: return early when cleanText is empty after @mention stripping
* fix(qqbot): close remaining review gaps — disposed guard, connectReject, token retry, RESUMED restore
* fix(qqbot): address wenshao review — RESUME restore removal, disposed guards, timer tracking, logging, heartbeat floor, requiredConfigFields, channel-registry error labels
* fix(qqbot): markdown fallback to plain text on rejection
* docs(qqbot): clarify markdown permission — Open Platform has no gate, FAQ is a different platform
* feat(qqbot): add Ark (msg_type=3) and Media (msg_type=7) message support
- types.ts: ArkKV, ArkPayload, FileType, MediaUploadRequest/Response, MediaPayload
- api.ts: uploadQQMedia() — file upload for rich media
- QQChannel.ts: sendArk(chatId, templateId, kv) + sendMedia(chatId, fileType, url, text?)
- C2C/group upload paths separated (file_info not interchangeable)
- file_type=4 (文件) blocked for groups per QQ API
- Embed (msg_type=4) skipped — QQ频道专用, not available for Bot Open Platform
* feat(qqbot): auto-route !ark / !media commands from LLM text via sendMessage
LLM outputs text — the channel now parses structured commands inline:
!ark(24, #TITLE#=标题, #META_DESC#=描述)
!media(image, https://example.com/photo.jpg, caption text)
parseArkCommand / parseMediaCommand extract at sendMessage entry;
normal text/markdown flow unchanged.
* feat(qqbot): inject channel instructions for ark/media commands
Sets config.instructions on connect() so the LLM learns about:
!ark(template_id, key=val, ...) — 3 default templates (23/24/37)
!media(type, url, [caption]) — image/video/voice/file
Fixes known debt: 'No channel instructions'.
* feat(qqbot): gate ark/media behind config flags (enableArk/enableMedia)
Both features default to false — opt-in via settings.json:
channels.my-qq.enableArk = true
channels.my-qq.enableMedia = true
Instructions injected conditionally; command routing gated per-flag.
* refactor(qqbot): extract resolveRoute() to eliminate duplication across sendMessage/sendArk/sendMedia
disposed check, token refresh, chatId validation, sandbox path selection
now in one place. All three methods call resolveRoute() instead of
repeating the same 15-line preamble.
* chore(qqbot): remove Ark and Media message support
Remove !ark() / !media() text parsing, sendArk/sendMedia methods,
uploadQQMedia, and all related types. The text-parsing approach
was too fragile against LLM output formatting. Only text/markdown
messaging remains.
* fix(qqbot): robustness patches for review findings
- Add { mode: 0o600 } to all writeFileSync calls (state/session files)
- Guard against stale WebSocket close event nuking new connection
- Add isReconnecting guard to prevent parallel reconnectWithRetry chains
- Reset isReconnecting flag in READY, RESUMED, and exhaustion paths
* docs(channel): add QQ Bot user documentation
Add user-facing documentation for the QQ Bot channel adapter:
- New docs/users/features/channels/qqbot.md covering setup, configuration,
QR code login, group chat, Markdown support, token management, connection
resilience, and troubleshooting
- Update docs/users/features/channels/_meta.ts to include QQ Bot in nav
- Update docs/users/features/channels/overview.md to reference QQ Bot
across the intro, quick start, type options, slash commands, and the
media platform differences table
* docs(qqbot): fix prerequisites — QR login needs no developer account
QR code login via qrConnect() does not require a developer account or
manual app registration. First qwen channel start is all you need.
* docs(qqbot): emphasize QR login, keep developer portal as secondary path
Both paths work (config → persisted file → QR scan), confirmed against
fetchToken() code. Reposition QR code login as the primary setup flow,
remove redundant tips/troubleshooting entries.
* docs(qqbot): remove Images and Files section — not supported in channel code
handleC2C/handleGroup both skip messages with no text content.
No media download or upload logic exists in this channel adapter.
* test(qqbot): add unit tests for send utilities
Add vitest test suite for QQ Bot channel following the weixin channel
testing patterns. Extract isValidChatId, hasMarkdownSyntax, and splitText
as exported module-level functions to enable direct testing.
- 27 tests covering: chatId SSRF validation, Markdown syntax detection, and
text chunking for QQ's 2000-char message limit
- Add vitest.config.ts and test script to qqbot package
- Register qqbot in root vitest workspace projects
Refs: #5202
* test(qqbot): add sendMessage flow tests with mocked API
Follow the weixin sendImage test pattern: mock sendQQMessage and
channel-base dependencies to test sendMessage end-to-end.
- C2C/group routing verification
- Markdown msg_type=2 vs plain text msg_type=0
- Markdown rejection fallback to plain text
- Disposed guard and error-stop behavior
- msg_id + msg_seq tracking for multi-chunk streaming
9 new tests, 36 total (all passing)
* test(qqbot): fix review issues — add missing edge cases
Self-review fixes:
- Fix misleading test name: 'returns early when chatId not in chatTypeMap'
→ 'defaults to C2C path for unknown chatId' (code doesn't return early)
- Add SSRF validation test: sendMessage rejects '../traversal' chatId
- Add network error test: thrown sendQQMessage caught by try/catch
- Add token expiration test: expired token + failed refresh → early return
- Hoist mockFetchAccessToken and set default resolved value in beforeEach
to prevent silent undefined-access failures in accidental token-refresh paths
39 tests, all passing
* test(qqbot): add api and accounts unit tests
Add api.test.ts (13 tests) and accounts.test.ts (8 tests) following
weixin channel vitest patterns: vi.hoisted() mocks, vi.mock() module
replacement, and dynamic import() after mock setup.
api.test.ts covers getApiBase, sendQQMessage, fetchAccessToken, and
fetchGatewayUrl — including HTTP errors, missing fields, and request
body format.
accounts.test.ts covers getCredsFilePath, loadCredentials (missing file,
corrupt JSON, missing fields, valid data), and saveCredentials (dir
creation + 0o600 permissions).
All 60 tests pass (39 existing + 21 new). tsc --build and eslint clean.
* chore(qqbot): suppress CodeQL ReDoS false positives
Add codeql[js/polynomial-redos] suppression comments for two
regexes flagged by CodeQL:
- hasMarkdownSyntax(): input is LLM-generated reply text,
never attacker-controlled in Qwen Code Channel context.
- handleGroup(): <@...> prefix is injected by QQ servers;
openid is assigned by QQ, not attacker-chosen.
Both paths have no practical exploit vector — an adversary
would need to either control an LLM's output or register a
malicious openid with QQ, neither of which is achievable.
* fix(qqbot): allow QR-code-only login and guard qrConnect return
- requiredConfigFields: [] — fetchToken() already resolves credentials
from config → persisted file → QR fallback chain. Blocking at config
validation prevented QR-code-only users from starting the channel.
- qrCodeLogin(): add bounds check for empty qrConnect() return value.
If the external library returns an empty array, throw descriptive
error instead of crashing with TypeError on creds.appId.
* chore(qqbot): add comments for requiredConfigFields and qrConnect guard
- index.ts: explain why requiredConfigFields is empty — fetchToken()
already resolves credentials via config → file → QR fallback chain.
Requiring appID/appSecret at config level would block QR-only users
from reaching the fallback through the built-in channel path.
- login.ts: clarify qrConnect() guard is a defensive robustness patch,
not a response to an observed failure. Verified by removing appID
from config and running qwen channel start — QR login triggers
correctly and returns valid credentials.
* fix(qqbot): replace quadratic regexes with linear patterns, remove failed suppress comments
* fix(qqbot): split hasMarkdownSyntax into individual tests to pass CodeQL
* fix(qqbot): replace markdown link regex with indexOf to eliminate CodeQL ReDoS
* feat(channels): add Feishu (Lark) channel adapter
* fix(channels/feishu): fix webhook stop button, memory leak, spin-wait timeout, and reaction cleanup
* fix(channels/feishu): fix security, stability and build issues from PR review
* fix(channels/feishu): fix card lifecycle, streaming limits, and download safety from CR round 2
* fix(channels/feishu): harden webhook, card lifecycle, and disconnect cleanup from CR round 3
* fix(feishu): clarify stoppedMessages JSDoc to match actual cleanup behavior
* fix(channels/feishu): handle post messages without language key wrapper in quote context
* fix(channels/feishu): fix webhook signature bypass, stop-button double-send, and blockStreaming duplicates from CR round 4
* fix(channels/feishu): harden card lifecycle, markdown splitting, and defensive guards from CR round 5
* fix(channels/feishu): harden card lifecycle, markdown splitting, and defensive guards from CR round 5
- Set cardCreationFailed on onPromptStart failure to prevent retry spiral
- Skip throttle updates when card creation permanently failed
- Handle code fences in hard-split and table-stripping fallbacks
- Use parity-based fence detection in splitByTables (align with splitChunks)
- Add cs.stopped and else branch in onPromptEnd to prevent timer race and state leak
- Mark cardState.stopped after busy-wait timeout to abandon orphaned in-flight creation
- Apply MAX_CARD_CHARS truncation with fence parity in onResponseComplete
- Sanitize senderId before <at> tag interpolation
- Use replaceAll + callback form for mention replacement
- Floor token expiry to prevent thundering herd on expire:0
- Add log for stop-button auth rejection
- Fix stoppedMessages JSDoc to match actual cleanup lifecycle
- Fix test fixture to match "still creating" scenario
- Fix typecheck errors in test file (TS2571, TS4111)
- Add stop-button auth negative path tests (operator mismatch, missing operator, missing sender)
- Replace spanning regex in table-stripping with line-by-line stripTables() to resolve CodeQL ReDoS warning
* fix(channels/feishu): fix HMAC bypass, prompt injection, SSRF, and card lifecycle from CR round 5-6
Security:
- Fix webhook HMAC bypass: use defineProperty(non-enumerable) for headers instead of prototype shadowing
- Fix cross-user prompt injection: mark quoted content as untrusted with explicit marker
- Fix SSRF: validate all Feishu IDs with FEISHU_ID_RE before URL interpolation in 6 endpoints
- Fix safeSenderId regex: add hyphen to character class so ou_abc-def-123 is not rejected
Card lifecycle:
- Set cardCreationFailed on onPromptStart failure to prevent retry spiral
- Skip throttle updates when card creation permanently failed
- Fallback to plain message delivery when cardCreationFailed with accumulated text
- Track creationTimer in CardSessionState so cleanupCard/disconnect can cancel orphaned card creation
- Add cs.stopped and else branch in onPromptEnd to prevent timer race and state leak
- Mark cardState.stopped after busy-wait timeout to abandon orphaned in-flight creation
- Apply MAX_CARD_CHARS truncation with fence parity in onResponseComplete
- Preserve atPrefix in streaming truncation to prevent @mention visual snap
- Account for suffix and fence reserve in truncation maxBody calculation
- Clean up auxiliary maps after handleInbound when gate rejects the message
- Clean up blockStreaming mode Map entries in onPromptEnd
- Skip bare @mention without question text
Markdown:
- Handle code fences in hard-split and table-stripping fallbacks
- Use parity-based fence detection in splitByTables (align with splitChunks)
- Replace spanning regex in table-stripping with line-by-line stripTables() to resolve CodeQL ReDoS warning
Defensive guards:
- Sanitize senderId before <at> tag interpolation
- Use replaceAll + callback form for mention replacement
- Floor token expiry to prevent thundering herd on expire:0
- Add log for stop-button auth rejection
Tests:
- Fix stoppedMessages JSDoc to match actual cleanup lifecycle
- Fix test fixture to match "still creating" scenario
- Fix typecheck errors in test file (TS2571, TS4111)
- Add stop-button auth negative path tests (operator mismatch, missing operator, missing sender)
- Assert cancelSession called in stop-button happy-path test
* fix(channels/feishu): add request timeouts, token dedup, and harden file/quote sanitization
* fix(channels/feishu): harden card lifecycle, webhook auth, and resource cleanup from CR round 7
* fix(channels/feishu): harden card lifecycle, mention handling, and error recovery
Add three dispatch modes for handling concurrent messages:
- steer (default): cancel current prompt and start new one
- collect: buffer messages and coalesce into follow-up prompt
- followup: queue messages for sequential processing
Introduce onPromptStart/onPromptEnd lifecycle hooks for working
indicators. These fire only when a prompt actually begins processing,
not for buffered (collect mode) or gated/blocked messages.
Refactor Telegram, WeChat, and DingTalk adapters to use the new hooks
instead of overriding handleInbound, simplifying the working indicator
pattern and ensuring correct behavior with dispatch modes.
This enables better UX for async workflows and prevents indicator
leaks when messages are buffered or cancelled.
- Add Attachments interface docs with handling examples
- Document block streaming configuration and behavior
- Update architecture diagrams to show attachment resolution
- Add Attachment type to exported types reference
- Update plugin-example README
Covers new structured attachment support and block streaming
that delivers responses as multiple progressive messages.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add comprehensive developer guide for building channel plugins
- Add user-facing docs for installing/configuring custom channel plugins
- Replace custom-channels.md with new plugins.md
- Rename @qwen-code/channel-mock to @qwen-code/channel-plugin-example
- Add messageId field to Envelope type for response correlation
This provides clear documentation for developers building custom channel
adapters and renames the mock package to better reflect its purpose as
a reference implementation example.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Document channels config in extension manifest
- Add guide for creating custom channel adapters
- Explain ChannelPlugin interface and ChannelBase usage
This enables users to extend the channel system with custom platform adapters.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add comprehensive DingTalk setup guide with prerequisites, configuration, and troubleshooting
- Add WeChat and DingTalk entries to channels navigation
This provides users with complete documentation for setting up and using DingTalk as a Qwen Code channel.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add /help, /clear (aliases: /reset, /new), /status commands to ChannelBase
- Commands are handled locally without agent round-trip
- TelegramAdapter skips "Working..." indicator for local commands
- Update docs to reflect new command structure
This provides a consistent command interface across all channel types
(Telegram, WeChat, etc.) with platform-specific extensibility.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add `qwen channel status` to check running service info
- Add `qwen channel stop` to gracefully stop the service
- Add PID file tracking to prevent duplicate service instances
- Update documentation with new commands and usage
This enables users to manage the channel service from another terminal
without needing to use Ctrl+C on the foreground process.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add session persistence to SessionRouter for crash recovery
- Add loadSession method to AcpBridge for restoring sessions
- Add ChannelBaseOptions to support external router injection
- Refactor start.ts to support both standalone and gateway modes
- Extract config utilities into separate module
This enables channels to recover sessions after bridge crashes and
supports running multiple channels under a gateway process.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add Media Support section to overview with images and files
- Document model option for multimodal channel support
- Add Images and Files section to Telegram guide
- Add complete WeChat (Weixin) setup guide with QR auth
This documents the new media handling capabilities added to both Telegram and WeChat channels.
- Add GroupGate class for group access control with three policies:
disabled (default), allowlist, and open
- Implement mention gating: bot only responds when @mentioned or replied to
in groups (configurable per-group)
- Extend Envelope type with isGroup, isMentioned, isReplyToBot fields
- Update TelegramAdapter to detect group context and mentions
- Add comprehensive documentation for group chat setup and troubleshooting
This enables using Qwen Code bots in Telegram groups with fine-grained
access control and mention-based activation to prevent noise.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add PairingStore for managing pending requests and approved users
- Update SenderGate to support pairing policy with code generation
- Add CLI commands: `qwen channel pairing list/approve`
- Document pairing flow with rules and usage examples
This allows unknown senders to request access via a pairing code
that the bot operator approves through the CLI.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add overview page explaining channels architecture and configuration
- Add Telegram channel setup guide with bot creation steps
- Add navigation entries for channels section
This documents the new Channels feature that allows users to interact
with Qwen Code agents from messaging platforms like Telegram.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>