* refactor(media): consolidate parallel media-kind unions onto canonical MediaKind
One canonical MediaKind union (media-core constants) replaces ~40
duplicate/parallel kind declarations across core and channel plugins;
channel-specific narrower contracts derive via Extract/Exclude. Also
fixes a review-caught fallback bug where a stored "unknown" reply-chain
kind preempted MIME inference and relabeled images as documents.
* refactor(ui): derive attachment kinds from MediaKind
* fix(telegram): drop type-dead unknown guard in reply-context kind fallback
* style(telegram): format media kind fallback
* feat(channels): batch 1 producers drop media placeholder bodies
Media-placeholder program batch 1: Google Chat, Zalo, LINE, and
Mattermost stop minting <media:kind> placeholder bodies. Media-only
messages carry an empty caption plus one structured fact per native
attachment (type-only when a download fails or is rejected, so payload
positions and kind signals stay aligned). The shared
formatMediaPlaceholderText SDK formatter renders text-only carriers
(Mattermost pending-room lines) from structured facts; per-channel
placeholder builders and the expected-count side channel are deleted.
* fix(mattermost): satisfy type, deadcode, and SDK manifest gates
* fix(line): deliver rich replies on the reply token instead of the push quota
A reply token carries five messages for free, while push spends the account's
monthly quota. Text rode the token and the rich/media bubbles were pushed after
it, so an extracted table vanished with a 429 once that quota was gone. Bundle
both onto the token and push only the overflow.
Quick replies now ride the trailing message instead of the last reply-token
slot: LINE hides them as soon as a newer message arrives, so an overflowing
batch pushed its bubbles after the buttons and wiped them.
* refactor(line): unify auto-reply batching
* test(line): remove unused token fixture
* refactor(line): delete legacy reply chunk path
* fix(line): recover text from rejected mixed batches
* test(line): split auto-reply recovery coverage
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): retry inbound media through the ingress drain on transient download failure
* fix(line): retry all transient inbound media failures
Co-authored-by: 許元豪 <146086744+edenfunf@users.noreply.github.com>
* style(line): format media retry test
* test(line): use scanner-safe quote token
* fix(line): narrow durable media retries
* fix(line): preserve status after body cleanup
* chore(changelog): avoid concurrent entry conflict
* style(line): format retry helper signature
* test(line): assert rejected media chunk
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): adopt durable ingress drain with ack gated on spool write
LINE acked webhooks 200 before processing events detached, so a crash or
dispatch failure silently lost inbound messages. Events now enqueue their raw
JSON into the channel ingress queue before the webhook responds; dispatch,
retry, dead-letter, and completion tombstones run through the core drain.
The in-memory replay guard is deleted; tombstone retention (30d/4096)
strictly covers its old 10min/4096 window, and message-id keys preserve the
guard keyspace so redeliveries under changed webhookEventIds still dedupe.
Autoreview: secret scanner false-positive on HMAC test fixtures
(test-channel-secret); full manual review of all five files + test matrix
performed instead. Part of the #109657 fleet adoption program (wave 1).
* style(line): oxfmt pass on drain adoption files
* fix(line): cap repeated ingress drain deliveries
* fix(line): preserve ingress lifecycle bounds
* style(line): satisfy ingress lint gates
* fix(line): run post-ack webhook processing on its own admitted work root
LINE acks the webhook and dispatches event processing fire-and-forget on
the same async chain. The HTTP request admission that chain inherited is
released as soon as the route handler returns, and a released admission
refuses subordinate queue work - so every LINE inbound agent turn fails
with "GatewayDrainingError: Gateway is draining; new tasks are not
accepted" even though the gateway is healthy. DMs, group mentions, and
postbacks are all affected; the user-visible symptom is the bot replying
"Sorry, I encountered an error processing your message." to everything.
Add runDetachedWebhookWork to the plugin-sdk webhook-request-guards
surface (a thin wrapper over the gateway independent-root continuation,
the same shape core uses in gateway/server/hooks.ts) and route all three
LINE ack-first dispatch sites through it: the gateway monitor handler
(the live path), and the createLineNodeWebhookHandler / Express
middleware handlers (public webhook building blocks an embedder can
register under the gateway). #65375 unified these three into one ack-first
pattern; keeping the detach consistent avoids re-introducing the same
latent defect in the two that are not on the live gateway path today.
The continuation is reserved synchronously while the request is still
admitted, so the detached processing stays accepted and a real restart
drain can wait for it instead of stranding it mid-turn.
Tests pin every layer: the guards suite proves detached post-ack work is
admitted after the request admission is released (and that the inherited
chain without the helper is refused); the monitor lifecycle suite and the
webhook-node suite assert each dispatch site goes through the detached
root. Red/green verified: reverting any dispatch fails its test.
* fix(plugin-sdk): account for runDetachedWebhookWork in public surface budget
* fix(channels): track detached webhook processing
Co-authored-by: 許元豪 <146086744+edenfunf@users.noreply.github.com>
* docs: refresh generated docs map
* chore(plugin-sdk): refresh API baseline
* fix(webhooks): preserve post-ack ordering
* test(plugin-sdk): satisfy detached work lint
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(line): wire base.allowlist config-edit adapter
LINE was the only DM/group channel without a base.allowlist adapter, so
`openclaw allow line` / the /allowlist command replied "does not support"
instead of editing the LINE allowlist. Reuse buildDmGroupAccountAllowlistAdapter
(DM + group + per-group-override scopes), matching Telegram/Signal; entry
normalization flows through the existing lineConfigAdapter.formatAllowFrom.
* fix(allowlist): preserve inherited account entries
* fix(allowlist): preserve all-scope store edits
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
* test(allowlist): import config type
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
* fix(allowlist): preserve empty effective overrides
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
* fix(allowlist): reject group-only store edits
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
* fix(allowlist): preserve cleared channel overrides
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): avoid exposing media URLs in errors
* test(line): cover media URL redaction paths
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): retry inbound media download while LINE reports 202
LINE answers the content endpoint with 202 and an empty body for a
short window while it prepares inbound media. The previous single-shot
download saved that empty body as a 0-byte file, silently dropping the
user's media. Poll the content endpoint with capped exponential
backoff until it stops returning 202, releasing each empty response
before retrying, and throw a visible error if it never becomes ready.
* fix(line): clean up media retry responses
* fix(line): use plugin runtime fetch seam
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): keep group history recorded during a mention turn
Group history cleanup ran a whole-key clear after each mention turn. Because
webhook dispatch is fire-and-forget, a plain (unmentioned) message can be
recorded while the agent is still handling a mention; the post-turn clear then
wiped the whole window, silently dropping that concurrent message from the
next turn's context.
Snapshot the identity keys the turn consumes up front and clear only those,
retaining anything recorded concurrently. The record path now stamps the LINE
message id so entries have a stable identity. Cleanup stays after the turn, so
a failed turn still leaves history intact for the retry. The snapshot/clear
helpers live in group-history.ts, next to the other group modules.
* test(line): cover group history retention when a mention turn fails
Assert that a mention turn whose processMessage throws leaves the group
history window intact (cleanup runs only after a successful turn), so the
retry still has the ambient context. Red/green verified: moving the cleanup
before the await fails this test.
* fix(line): capture consumed group history at the context read boundary
The consumed-key snapshot ran before media download and context
construction, while buildLineMessageContext copied the window later.
A plain message recorded between those awaits was included in the
turn's InboundHistory yet missing from the consumed set, so cleanup
retained it and the next mention received it twice.
Read the window and capture its identity keys in one synchronous step
in the handler (snapshotLineGroupHistory), then pass the materialized
inboundHistory into buildLineMessageContext instead of the live map.
The consumed set is now derived from the exact entries the turn reads,
so an entry is either in the context and cleared, or recorded later
and retained - there is no window where both can be true.
Regression tests park a mention turn inside context construction,
record an ambient message mid-window, and assert it stays out of that
turn's InboundHistory, survives cleanup, and is consumed exactly once
by the next mention. Red/green verified against the previous snapshot
placement.
* test(line): point history-window guardrail at group-history.ts
The group-history cleanup fix moved the createChannelHistoryWindow facade
call out of bot-message-context.ts (which now receives inboundHistory as a
parameter) into the new group-history.ts. Update the cross-channel
historyWindowFiles ledger to match, mirroring telegram's group-history-window.ts
entry already in the same list. Fixes the message-turn-guardrails 'keeps
migrated history users on the channel history window facade' assertion that
build-artifacts flagged.
* fix(line): reserve history across concurrent turns
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
* test(line): complete webhook message fixtures
* test(line): use explicit token placeholders
* refactor(line): keep reservation type private
* refactor(line): render reservations through history facade
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): honor channelData.line.mediaKind on the reply-token path
The reply-token delivery built every media message with createImageMessage,
ignoring channelData.line.mediaKind (and previewImageUrl/durationMs/trackingId),
so a video/audio reply was silently downgraded to a broken image. The push path
already honored mediaKind via resolveLineOutboundMedia + buildLineMediaMessageObject.
Route reply-token media through those same helpers (relocated to outbound-media.ts
and reused by both paths) via an injected buildMediaMessage dep wired in monitor.ts,
preserving the delivery file's dependency-injection boundary. Generic media without
LINE-specific options keeps the image route; a media that cannot be built surfaces
as a visible partial delivery so the text still reaches the user.
* refactor(line): unify reply media delivery
* fix(line): normalize media delivery failures
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): use precise control-command check for group mention bypass
Group requireMention bypass is gated on `command.hasControlCommand`, but LINE
fed it the broad `shouldComputeCommandAuthorized` detector, which is true for
any inline "/x"/"!x" token. An allowlisted member's plain message like
"cd /home" would satisfy the bypass and reach the agent even though the bot
was never mentioned.
Groups now use the precise `hasControlCommand` (message starts with a real
command); DMs keep the broad detector since they have no mention gate. This
mirrors googlechat's monitor-access wiring. The group/DM split lives in a
small `resolveLineControlCommand` helper in group-policy.ts, next to
resolveLineGroupRequireMention, so bot-handlers.ts stays within its size cap.
* test(line): assert group mention-bypass with a registered control command
The positive regression case sent `!status`, which the mocked
command-auth module treated as a control command via a naive
`startsWith("!")`. Production `hasControlCommand` matches the message
body against registered command aliases (e.g. `/status`), so `!status`
is only an inline token, not a real control command. The test could
therefore pass without proving that a genuine control command still
bypasses requireMention.
Use the registered `/status` alias for the bypass cases and make the
mock reflect the real split: `hasControlCommand` = starts with a
registered alias (precise); `shouldComputeCommandAuthorized` =
`hasInlineCommandTokens` regex over `/x`/`!x` (broad). The two
detectors are now distinguishable, so the group precise-detector path
is actually exercised.
* refactor(line): centralize command detection
* test(line): complete direct message fixture
* docs(changelog): credit LINE command fix
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(line): drop blank entries from trailing commas in agenda/device directives
* fix(line): drop blank device action labels
Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(config): add canonical group-policy scope-tree resolver
* chore(plugin-sdk): refresh API baseline hash for scope-tree exports
* refactor(channels): migrate googlechat, imessage, and whatsapp group policy onto the scope tree
* chore(plugin-sdk): refresh API baseline hash for groups scope-tree builder
* refactor(channels): migrate line, qqbot, and mattermost group policy onto the scope tree
* chore(plugin-sdk): refresh API baseline hash for case-insensitive scope key helper
* chore(plugin-sdk): refresh API baseline hash after rebase onto current main
* chore(plugin-sdk): refresh API baseline hash after rebase
* chore(plugin-sdk): refresh API baseline hash after rebase
* chore(plugin-sdk): refresh API baseline hash after rebase
Prevent LINE group allowlist policy from inheriting DM allowFrom entries, so open DMs do not broaden group access. Keep group-specific allowlists and per-group overrides as the only group admission sources, and document the scoped behavior.
Co-authored-by: pgondhi987 <pgondhi987@users.noreply.github.com>
* fix(extensions): make indexed access explicit across channel plugins
Transport-payload-safe burn-down: malformed Telegram/Discord/QQ/LINE
and sibling channel input keeps existing skip paths; no synthesized
fields, no new throws in delivery loops. Zalo escape sentinels preserve
literal matches instead of undefined replacements.
* fix(extensions): make indexed access explicit across provider and memory plugins
Stream and model iteration, tool-block guards, capture guards, and
sparse accumulators; singleton model reads carry named invariants.
* fix(extensions): make indexed access explicit across tooling plugins, flip the extensions lane
Remaining plugins (oc-path, qa-lab, browser, logbook, and siblings) plus
the tsconfig.extensions.json flag flip. Cleanup: logbook sampleFrames
NaN index at max=1, QA retry clamp at non-positive attempts, dead Canvas
probe and OpenShell no-op slice removed, twitch test setup leak excluded
from the prod lane.
* refactor(plugin-sdk): expose expectDefined via a focused SDK subpath
Extensions imported @openclaw/normalization-core directly, crossing the
external-plugin packaging boundary (it only worked because the runtime
builder bundles undeclared workspace helpers). expect-runtime joins the
canonical entrypoints JSON, generated exports, API baseline, docs, and
subpath contract test; all 78 extension imports now use the SDK seam.
Two scanner-shaped locals renamed for review-bundle hygiene.
* chore(plugin-sdk): raise surface budgets for the expect-runtime subpath
One new entrypoint with one callable export, added intentionally as the
packaging-honest seam for extension invariant helpers.
* fix(matrix): truncate inbound preview on UTF-16 code-point boundary
The matrix inbound verbose preview used bodyText.slice(0, 200) before logging.
When a supplementary-plane character (emoji, extended CJK) straddles the 200th
code unit, the slice splits its surrogate pair and emits a lone surrogate into
the verbose log line. Lone surrogates corrupt JSON log serialization, break
terminal rendering, and crash UTF-8-validating log shippers.
Use the shared truncateUtf16Safe helper (already used by the mattermost sibling
monitor after #101630) to keep complete surrogate pairs intact. No behavior
change for ASCII input; the preview is still capped at 200 code units.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(matrix): fix inbound-preview test for lint and lib target
- Avoid String.prototype.isWellFormed (ES2024 lib): use the encodeURIComponent
well-formedness probe instead, which works on the project's TS lib target.
- Replace literal string concatenation with template strings to satisfy the
no-useless-concat lint rule. No behavior change in the assertions.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(channels): keep inbound log previews UTF-16 safe
* test(line): type verbose preview fixtures
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>