From 07a8c3c79594586d2b237d462fc70c39a0235fcd Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 21:24:54 +0300 Subject: [PATCH 01/19] docs(automation): record the rules UX & correctness audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five parallel auditors over the automation surface (canvas editor, rules list, actions API, packages/systems/actions, bot executor/eventBridge/ registry), each finding put through an adversarial refutation pass. 62 raised, 47 confirmed, 5 refuted, 10 unverified. Covers the three reported problems with root causes and file:line: (A) required action fields don't block save — validateAction emits "warning" and validation.valid counts errors only; the server gate checks only action.type (B) per-trigger variables reach autocomplete but not the action Variables tab, and step-mode actions lose both tab and preview (C) trigger filters fail *open* when the event context lacks the datum, so exclude filters silently don't exclude Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-07-27-automation-rules-audit.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-automation-rules-audit.md diff --git a/docs/superpowers/specs/2026-07-27-automation-rules-audit.md b/docs/superpowers/specs/2026-07-27-automation-rules-audit.md new file mode 100644 index 00000000..d734b8fd --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-automation-rules-audit.md @@ -0,0 +1,164 @@ +# Automation / Rules — UX & correctness audit + +**Date:** 2026-07-27 +**Branch:** `worktree-fix-automation-rules-ux` +**Scope:** the whole automation surface — dashboard canvas editor, rules list, actions API, `packages/systems/src/actions`, and the bot-side executor/eventBridge/registry. +**Method:** five parallel auditors over independent dimensions, each finding then put through an adversarial refutation pass. 62 raised, 47 confirmed, 5 refuted, 10 unverified (the verifier hit a session limit). + +## Summary + +The automation feature is architecturally sound and further along than it looks: the bot really does execute the v2 step graph ([`executor.ts:274`](../../apps/bot/src/features/automation/system/executor.ts#L274)), with iteration caps, delay caps, per-(guild, event) rate limiting, and template-injection defences that are pinned by tests. The canvas, context menu, keyboard nav and variable-field editor are all real, working work. + +The problem is that **almost nothing that goes wrong is visible to the user.** The same failure shape recurs at every layer: a misconfiguration is accepted without complaint, does nothing at runtime, and is then recorded as a *success*. A rule with no channel saves green, never fires, and reports 100% success. A trigger filter whose data the event does not carry is skipped entirely, so an "exclude @Staff" rule announces staff bans anyway. `addRole` on a reaction trigger silently no-ops. `hasRole` conditions always take the else-branch. In every one of these the dashboard actively tells the user everything is fine. + +**The single most important fix is to stop lying about success** — make missing configuration an error at save time, a thrown failure at runtime, and a visible state in the rule list. Everything else in this document is secondary to that. + +The three reported problems are all real and all have narrow, well-understood root causes. Two of them (A and C) are instances of the "silent acceptance" pattern above; the third (B) is a partial wiring gap rather than the total absence the report suggested. + +## The three reported problems + +### (A) Action nodes don't force required fields before save + +**Root cause.** [`workflow-validation.ts:213-217`](../../apps/dashboard/src/client/features/automation/lib/workflow-validation.ts#L213-L217) reports a missing required field at `level: "warning"`, and [`workflow-validation.ts:61`](../../apps/dashboard/src/client/features/automation/lib/workflow-validation.ts#L61) computes `valid` from **errors only**. Save is disabled on `!validation.valid`, so warnings never block it. The server does not compensate: [`routes.ts:85`](../../apps/dashboard/src/server/features/actions/routes.ts#L85) checks only that `action.type` is known plus the webhook URL scheme — every other required field is unchecked, so the Undo-restore and Duplicate paths in `rules.tsx` can re-POST a dead rule too. + +**What the user sees.** Pick "Send Message", skip the channel, click Save. The button is enabled, the toolbar shows an amber "1 issue" chip rather than a red one, a green "Rule created" toast fires, and the rule sits in the list looking healthy. It then never does anything, forever. Every action type has such a configuration: `sendMessage` (channelId, message), `sendEmbed` (channelId), `sendDM` (message), `addRole`/`removeRole` (roleId), `logToChannel` (channelId), `setNickname` (nickname), `createThread` (channelId, threadName), `addReaction` (emoji). Five of the six preset templates ship in exactly this state — the onboarding path is the fastest way to build a broken rule. + +**Fix.** Promote the required-field branch to `level: "error"` (keeping genuinely advisory items — `conditionValueEmpty`, `delayNotConnected`, `stepUnreachable` — as warnings so the split stays meaningful); enforce the same `ACTION_TYPE_FIELDS` contract server-side in `validateRuleBody`; and surface it per-field in `ActionFields.tsx` with `aria-invalid` + an inline message instead of only a decorative asterisk. Then make the runtime honest: a missing required field must `throw` so it is logged `success: false`, not `return` silently. + +### (B) Per-trigger variables don't reach the action's completion + +**Root cause.** The autocomplete path *is* wired — `buildAutomationVariables(constants, eventType)` at [`NodeDetailPanel.tsx:317`](../../apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx#L317) feeds the trigger-scoped list into `VariableEditor`. Three real gaps sit around it: + +1. The action panel's **Variables tab** ignores the trigger entirely: [`NodeDetailPanel.tsx:432-437`](../../apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx#L432-L437) renders `Object.keys(constants.templateVariables)` — all 27 tokens — while the trigger panel's tab renders the scoped set. So the reference list contradicts the autocomplete, and contradicts the editor's own unknown-token validator. +2. The **step-mode action editor has no Variables tab and no message preview at all** ([`NodeDetailPanel.tsx:544-605`](../../apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx#L544-L605)). Adding one condition or delay converts the rule to step mode and strips both from every action in it. +3. **Some fields never get the editor.** `ActionFields.tsx:19-27` gates the `VariableEditor` on a hardcoded `VARIABLE_FIELD_KEYS` set, so template-bearing fields outside it fall back to a plain input with no autocomplete and no highlighting. + +Underneath, `EVENT_TYPE_VARIABLES` also **over-promises**: it advertises `{channel*}` on member/ban/role events and `{user*}` on channel events that `eventBridge` never populates, so the preview renders `#general`/`@Ada` where the bot will post `Unknown Channel`/`Unknown User`. + +**What the user sees.** Autocomplete works, but the Variables reference beside it lists tokens the trigger cannot provide; add a condition and both the reference and the preview vanish; and some of the tokens that *are* offered render as `Unknown` at runtime. + +**Fix.** Pass the scoped list to the action Variables tab, give `StepPanel` the same tabs/preview as `ActionPanel`, drive `VARIABLE_FIELD_KEYS` off the field descriptors rather than a hardcoded set, and prune `EVENT_TYPE_VARIABLES` down to what `eventBridge` actually populates (or populate what it promises). + +### (C) Trigger filters aren't convenient + +This one is worse than "inconvenient" — the filters are **unreliable**, and that outranks the ergonomics. + +**Root cause — correctness.** [`executor.ts:54-79`](../../apps/bot/src/features/automation/system/executor.ts#L54-L79) guards every filter on the presence of its datum: `if (conditions.excludeRoleIds?.length && context.member)`. When the event context lacks the field the filter is **skipped, not failed**. `buildBanContext` never sets `member`, so "exclude @Staff" on a `memberBanned` rule announces staff bans anyway. Same for role filters on `reactionAdded` and `memberLeave`. Worse, on `threadCreated`/`channelCreated` the context channel *is* the new thread, so an include-channel filter can never match and the rule silently never fires. + +**Root cause — ergonomics.** Users add member filters by pasting raw 17–20 digit snowflakes ([`ConditionsEditor.tsx:69-128`](../../apps/dashboard/src/client/features/automation/components/ConditionsEditor.tsx#L69-L128)) and saved users render as bare digits — there is no member-search endpoint anywhere in the dashboard to build a picker on. The channel picker cannot offer announcement, forum, stage or thread channels. All six filters are offered for every event type including combinations the runtime can never evaluate. Include/exclude reuse identical labels and the four comboboxes have no programmatic label, so a screen-reader user cannot tell them apart. And active filters are invisible outside the open panel — not on the canvas node, not in the rule list. + +**Fix.** Pick one explicit policy — a configured filter whose datum is missing should **fail closed** (rule does not fire) rather than be dropped — and populate the contexts where the data is obtainable. Then make the editor event-aware so it never offers a filter the runtime cannot evaluate for the selected trigger, and add a filter summary to the trigger node. + +## Other confirmed defects + + +### High (12) + +| # | Defect | Location | Impact | +|---|---|---|---| +| 1 | Actions with a missing required field silently no-op at runtime AND are recorded as successful executions | `bot/features/automation/system/executor.ts:295` | A moderator's "auto-role on join" rule never assigns the role | +| 2 | Server gate validates only action.type and webhook URL — every other required field is unchecked | `~/server/features/actions/routes.ts:85` | Even after the client-side warning/error split is fixed, the real gate still accepts a dead rule | +| 3 | Five of six preset rule templates produce a rule with a required field blank | `~/client/routes/guild/$guildId/rules.tsx:68` | The product's own onboarding path is the fastest way to create a broken rule | +| 4 | Trigger filters silently no-op when the event context lacks the field — exclude filters especially, so rules fire on users/channels they were configured to skip | `bot/features/automation/system/executor.ts:60` | An admin builds a `memberBanned` rule that DMs a moderator log and adds `Exclude → Roles: @Staff` so staff bans are not announced | +| 5 | Include-channel filter can never match threadCreated/channelCreated (the context channel is the new thread/channel), and the picker cannot offer announcement, forum, stage or thread channels at all | `bot/features/automation/system/eventBridge.ts:379` | A user creates a `threadCreated` rule and adds `Include → Channels: #support` intending "only threads under #support" | +| 6 | hasRole / notHasRole always false unless the unrelated condition field happens to be populated | `bot/features/automation/system/executor.ts:117` | User builds the canonical flow: on member join, IF member hasRole @Verified then welcome, ELSE send verification DM | +| 7 | addRole / removeRole silently do nothing on any event whose context has no member, and are logged as success | `bot/features/automation/system/registry.ts:85` | The classic reaction-role automation - 'when someone reacts with a check mark, give them @Member' - saves cleanly, shows a green node, and does absolutely nothing at runtime | +| 8 | reactionAdded / reactionRemoved / messageDeleted triggers never fire for uncached messages because the client enables no Partials | `bot/shared/client/ExtendedClient.ts:9` | An admin sets up 'when someone reacts to this rules message, give them a role' on a pinned message posted last month, or 'log every deleted message' | +| 9 | The /actions bot command silently downgrades a canvas-built rule to a flat action list, destroying conditions, delays and branches | `bot/features/general/commands/actions.ts:761` | A user spends time building a branching workflow in the canvas, then runs /actions edit rule:welcome message:hi (or add-action) in Discord | +| 10 | Missing required action fields are only "warnings", so Save stays enabled and a rule that can never execute is persisted | `~/client/features/automation/lib/workflow-validation.ts:213` | User picks trigger "Member Join", picks action "Send Message", never opens the Channel picker, sees the toolbar say "1 issue" in amber, and Save is fully enabled | +| 11 | Changing an action's type discards every field the user already filled in, with no warning and no undo | `~/client/features/automation/workflow/NodeDetailPanel.tsx:320` | User composes a 400-character welcome message under "Send Message", realises it should be a DM, switches the type to "Send DM" — both action types have a `message` field, but the message … | +| 12 | Escape typed inside the rule-name or any other input closes the entire editor instead of the field | `~/client/features/automation/workflow/useWorkflowKeyboard.ts:55` | User types a rule name, decides the text is wrong, presses Escape out of habit to revert the field, and the whole full-screen editor closes | + +### Medium (19) + +| # | Defect | Location | Impact | +|---|---|---|---| +| 1 | The webhook Headers field writes a raw string into a field typed as a record, making the rule unsavable with an untranslated Zod message | `~/client/features/automation/components/ActionFields.tsx:155` | A user configuring sendWebhook types the exact JSON the placeholder shows them, clicks Save, and gets a red banner reading "Expected object, received string" — English-only regardless of … | +| 2 | The server validates body.actions but persists body.steps unchecked — and the bot executes steps in preference to actions | `~/server/features/actions/routes.ts:106` | The declared server contract does not cover the code path that actually executes | +| 3 | Step-mode action editor has no Variables tab and no message preview at all — adding a single condition or delay strips both from every action in the rule | `~/client/features/automation/workflow/NodeDetailPanel.tsx:560` | User builds a Send Message rule, sees the variable reference and the live Discord preview, then adds one condition to branch on channel | +| 4 | Before a trigger is picked, every variable typed into an action is flagged "Unknown variable" and autocomplete never opens | `~/client/shared/ui/variable-field/automationVariables.ts:40` | A first-time user drags in an action before choosing a trigger (nothing stops them), types `{user}` in the Message field, and the field turns red: "Unknown variable {user}" — for the sing… | +| 5 | EVENT_TYPE_VARIABLES promises {channel*} on member/ban/role events and {user*} on channel events, but the bot never populates them — the preview shows #general/@Ada while the bot posts "Unknown Channel"/"Unknown User" | `bot/features/automation/system/eventBridge.ts:32` | User writes a memberJoin welcome message "Welcome {user} to {channel}!" | +| 6 | ConditionsEditor offers all six filters for every event type, including combinations the bot can never evaluate, and nothing validates them | `~/client/features/automation/workflow/NodeDetailPanel.tsx:285` | A user configuring a `channelCreated` rule sees Include → Users and Include → Roles pickers, picks "only when @Admin creates a channel", saves, sees no warning anywhere, and the rule fire… | +| 7 | User filters require pasting raw 17-20 digit snowflakes and render saved users as bare digits — there is no member search endpoint anywhere in the dashboard | `~/client/features/automation/components/ConditionsEditor.tsx:123` | To exclude one troublesome member from an automation, an admin must leave the dashboard, enable Discord developer mode, right-click the user, copy the ID, and paste it back | +| 8 | The four channel/role filter comboboxes have no programmatic label, and include vs exclude are indistinguishable to a screen reader | `~/client/shared/ui/discord-multi-select.tsx:108` | A screen-reader user tabbing the Filters tab hears: "Add channel..., button / Add role..., button / Users, edit / Add user ID, button / Exclude channel..., button / Exclude role..., butto… | +| 9 | Every event/action type label and description in the automation UI — including the rules-list event filter — is hardcoded English served from the API, in an app with 48 locales | `~/client/routes/guild/$guildId/rules.tsx:428` | An Arabic or Japanese admin opens Automation: the page chrome, filter placeholders and buttons are localised, but the event-type filter dropdown reads "Member Join / Message Deleted / Boo… | +| 10 | Trigger include/exclude filters are silently skipped when the event context lacks the value (fail open) | `bot/features/automation/system/executor.ts:54` | An admin restricts a reaction rule to 'only members with @Staff' or 'exclude @Muted' | +| 11 | ActionLog records success=true for actions that never ran, swallowed failures, and unknown action types, so the dashboard success rate is fiction | `bot/features/automation/system/executor.ts:189` | A user whose welcome DM is blocked by everyone's privacy settings, or whose rule has an unfilled channel field (the editor only warns, so it saves), sees 'Success rate 100%' on the rules … | +| 12 | sendWebhook SSRF: the https + private-IP check is bypassed by a redirect, and the response body is read unbounded | `bot/features/automation/system/registry.ts:226` | Any guild admin (or anyone who compromises one dashboard session) can point a webhook action at their own https endpoint that redirects to the cloud metadata service or an internal host, … | +| 13 | EVENT_TYPE_VARIABLES advertises variables the event context never populates; templates render Unknown / 0 instead | `systems/actions/constants.ts:224` | The autocomplete for a Member Join trigger offers {channel} and {channel.name}; the user inserts them, the Discord preview renders them, and the live message posts the literal text 'Unkno… | +| 14 | A delay step blocks every lower-priority rule for that event, is lost on restart, and is silently skipped at the concurrency cap | `bot/features/automation/system/executor.ts:235` | A user drags a 'wait 5 minutes then send a reminder' node into a high-priority join rule; their other join rules (autorole, log) also stop running for five minutes per join | +| 15 | Rate limiting silently aborts rules mid-workflow with no user-visible signal | `bot/features/automation/system/executor.ts:184` | On a busy server a messageCreated rule stops firing after 60 actions in a minute and users report 'the bot randomly ignores some messages' | +| 16 | Deleting the last step drops the canvas back to linear mode and resurrects the stale pre-conversion actions array | `~/client/features/automation/workflow/useWorkflowNodes.ts:409` | User converts a 3-action rule to a workflow, reworks it into 2 steps, then deletes those 2 steps to start over | +| 17 | Editing an existing rule autosaves a draft that is never read back, and closing the editor discards the edits with no confirmation and no beforeunload guard | `~/client/features/automation/workflow/WorkflowEditor.tsx:109` | User opens an existing rule, spends five minutes rewriting the embed and rewiring two steps, then hits Escape or the browser Back button | +| 18 | Step mode enforces no action/step/condition ceiling, so the server rejects the finished rule with a raw untranslated English error | `~/client/features/automation/workflow/WorkflowEditor.tsx:632` | User builds a 6-action / 4-condition workflow with the buttons the UI happily keeps enabled, the toolbar reports "Ready", then Save fails with the literal string "Max 5 actions" — in Engl… | +| 19 | The only aggregate validation explanation is a hover tooltip on a non-focusable div, and the disabled Save button gives no accessible reason or way to reach the bad node | `~/client/features/automation/workflow/WorkflowEditor.tsx:659` | A keyboard or screen-reader user sees a permanently greyed Save button and is never told why | + +### Low (14) + +| # | Defect | Location | Impact | +|---|---|---|---| +| 1 | ActionFields has no inline error affordance — required fields get only a decorative asterisk, no aria-invalid, no message, no touched state | `~/client/features/automation/components/ActionFields.tsx:79` | A screen-reader user tabs into the Channel picker for a Send Message action | +| 2 | RULE_NAME_REGEX guards the bot command but not the dashboard API, so its stated injection protection is bypassable | `~/server/features/actions/routes.ts:59` | A dashboard admin names a rule `@everyone` or `<@&1234>` | +| 3 | Creating a rule with a duplicate name hits an unhandled Prisma P2002 and returns a 500 with a generic client message | `~/server/features/actions/routes.ts:249` | An admin uses the "Welcome Message" template twice (both drafts hard-code `name: t("templates.welcomeMessageName")` at rules.tsx:52), or simply retypes a name they already used | +| 4 | No test anywhere asserts required-field enforcement, and the one server test that touches it asserts the broken behaviour | `apps/dashboard/tests/server/features/actions/actions.test.ts:236` | Any fix to the required-field gate can be silently reverted, and in fact the current test suite would FAIL the correct behaviour — a maintainer who fixes routes.ts to reject an action wit… | +| 5 | Nothing validates action templates against the selected trigger at rule level — a rule full of tokens the trigger cannot provide saves cleanly, and changing the trigger silently invalidates every template | `~/client/features/automation/lib/workflow-validation.ts:206` | User writes a messageCreated rule using {message.content}, later switches the trigger to memberJoin from the trigger panel | +| 6 | DiscordMultiSelect — the component that renders four of the six trigger filters — ships hardcoded English strings and never calls useTranslation | `~/client/shared/ui/discord-multi-select.tsx:104` | In any non-English locale, opening Include → Channels shows a popover whose search box says "Search...", whose empty state says "No channels available", and whose trigger reads "3 selecte… | +| 7 | Active trigger filters are invisible outside the open detail panel — neither the canvas trigger node nor the rule list shows that a rule is filtered | `~/client/features/automation/workflow/nodes/TriggerNode.tsx:25` | A rule stops firing | +| 8 | The rules-list event filter keeps filtering after its option disappears, leaving a control with no readable value and an empty list | `~/client/routes/guild/$guildId/rules.tsx:206` | Admin filters to "Member Join" (2 rules), deletes both | +| 9 | The server stores trigger conditions verbatim — no snowflake, guild-ownership or key validation — and deleted channels/roles then render as bare IDs | `~/server/features/actions/routes.ts:260` | A moderator deletes #welcome in Discord | +| 10 | Include semantics (OR within a group, AND across groups, empty = match all) are never stated, and the one explanatory sentence is ambiguous | `i18n/locales/en/rules.json:146` | An admin adds Include → Roles: @Member and @Verified expecting "members who have BOTH roles" (the copy says "matches all include filters"), but the bot fires for anyone with EITHER | +| 11 | The canvas allows a loop and the executor has no cycle detection - looped actions run up to 20 times per event | `bot/features/automation/system/executor.ts:174` | A user connects the last node back to an earlier one (easy to do by accident when re-routing an edge) | +| 12 | The API stores step contents unvalidated; a condition step without a value throws inside the one code path with no try/catch, aborting all remaining rules | `~/server/features/actions/routes.ts:106` | A hand-built or third-party API call (the endpoints are documented via withDocs), or any future client bug, persists a condition step with no value or an unknown operator | +| 13 | The entire v2 step-graph execution path is untested | `apps/bot/tests/features/automation/system/executor.test.ts:45` | Every defect above in condition evaluation and step traversal shipped undetected, and the project's own CLAUDE.md rule ('Every Feature Must Include Tests | +| 14 | Trigger filters are invisible everywhere except inside the trigger panel — not on the canvas, not in the rule list | `~/client/features/automation/workflow/useWorkflowNodes.ts:23` | User restricts a rule to one channel, saves, and later wonders why it never fires elsewhere | + +### Unverified (10) — the adversarial pass hit the session limit before these ran + +| Defect | Location | Note | +|---|---|---| +| The template gallery only exists while the guild has zero rules, and the editor itself offers no onboarding | `~/client/routes/guild/$guildId/rules.tsx:520` | medium / ux | +| Condition and Delay nodes never render their warning validation state, so their issues have no anchor on the canvas | `~/client/features/automation/workflow/nodes/ConditionNode.tsx:31` | medium / validation | +| The action panel's Variables tab lists every template variable regardless of the trigger, and step-mode action panels have no Variables tab at all | `~/client/features/automation/workflow/NodeDetailPanel.tsx:434` | medium / parity | +| An empty draft is written 500 ms after the editor opens, so the next "Create Rule" greets the user with a bogus "Draft restored" banner | `~/client/features/automation/workflow/WorkflowEditor.tsx:182` | medium / ux | +| Deleting two or more selected action nodes at once removes the wrong actions because the indices shift between removals | `~/client/features/automation/workflow/WorkflowEditor.tsx:229` | medium / correctness | +| The rule list has no way to show that a saved rule is misconfigured — its only "not configured" marker is unreachable dead code | `~/client/features/automation/components/RuleList.tsx:269` | medium / ux | +| The full-screen editor is portalled over the app without focus containment, so Tab reaches the sidebar behind it and navigating away destroys unsaved work | `~/client/features/automation/workflow/WorkflowEditor.tsx:894` | medium / a11y | +| Every trigger name, action name, description, field label and placeholder is hardcoded English served from the API, in a 48-locale dashboard | `systems/actions/constants.ts:18` | medium / i18n | +| Trigger filters clear all six lists on a single unconfirmed click, and member filters require pasting raw snowflake IDs while channels and roles get pickers | `~/client/features/automation/components/ConditionsEditor.tsx:287` | medium / ux | +| The rule list step counter interpolates `count` without plural categories, so it reads "+1 steps" in English and is ungrammatical in every plural-rich locale | `~/client/features/automation/components/RuleList.tsx:312` | low / i18n | +## Refuted — deliberately not fixed + +Five findings did not survive the adversarial pass and are recorded here so they are not re-raised: + +- *"Automation reimplements the variable picker instead of reusing VariableBrowser"* — false premise; the shared primitive is already used for insertion. +- *"`buildAutomationVariables` is unmemoized, breaking downstream memos"* — mechanically true, no reachable impact (the array is small and the claimed keystroke cost does not occur). +- *"Rules-list filters are component state and reset on navigation"* — accurate reading of the code, but the load-bearing user scenario is unreachable. +- *"The rules-list event filter should be a SearchableSelect"* — the code quotes are right, the severity was not; folded into the i18n item instead. +- *"Adding a Condition or Delay permanently deletes unconfigured actions"* — `convertToStepMode` does not lose data the way claimed. + +## Suggested fix order + +Each item is one commit, with the tests it needs. FluxCore requires tests for every change. + +1. **Make required fields block the save.** `workflow-validation.ts` warning→error; server-side `ACTION_TYPE_FIELDS` enforcement in `validateRuleBody`; inline `aria-invalid` + message in `ActionFields`. Closes **(A)**. + *Tests:* per-action-type cases in `workflow-validation.test.ts`; server rejection cases in `tests/server/features/actions/actions.test.ts` (and fix the existing test that asserts the broken behaviour). +2. **Stop reporting phantom successes.** Executors throw on missing config instead of returning; `executor.ts` unknown-action branch must not fall through to the success log; resolve the member in `addRole`/`removeRole`. + *Tests:* `apps/bot/tests/features/automation/system/executor.test.ts` — assert `success: false` and the error text. +3. **Fix filter semantics (fail closed) and populate missing contexts.** `matchesConditions` + `buildBanContext`/`buildReactionContext`. Closes half of **(C)**. + *Tests:* executor unit tests per event/filter combination. +4. **Make the conditions editor event-aware** and add a filter summary to the trigger node. Closes the rest of **(C)**. + *Tests:* `ConditionsEditor` component tests; `useWorkflowNodes.test.ts` for the summary. +5. **Scope the action Variables tab to the trigger; give StepPanel tabs + preview; derive `VARIABLE_FIELD_KEYS` from descriptors.** Closes **(B)**. + *Tests:* `NodeDetailPanel` component tests asserting the scoped token set in both modes. +6. **Reconcile `EVENT_TYPE_VARIABLES` with what `eventBridge` populates.** Finishes **(B)**. + *Tests:* a matrix test in `packages/systems/tests/` pinning promised-vs-populated per event type. +7. **i18n the automation vocabulary.** Move event/action labels, descriptions, field labels and placeholders out of `packages/systems/src/actions/constants.ts` into `rules.json` keys resolved client-side; translate across all 48 locales. + *Tests:* key-parity test. +8. **Editor data-loss fixes.** Preserve shared fields on action-type change; scope Escape to the focused control; unsaved-changes guard on close; fix the multi-node delete index bug. + *Tests:* `WorkflowEditor.test.tsx`. +9. **Webhook headers JSON field**, SSRF redirect hardening, and the remaining low-severity items. + +## Not in scope of the reported complaints, but worth flagging + +- [`ExtendedClient.ts:9`](../../apps/bot/src/shared/client/ExtendedClient.ts#L9) enables **no Partials**, so `reactionAdded`/`reactionRemoved`/`messageDeleted` triggers never fire for messages the bot has not cached since its last restart. Reaction-role automations on an old pinned message simply never work. This is a bot-wide fix, not an automation-only one. +- [`actions.ts:761`](../../apps/bot/src/features/general/commands/actions.ts#L761) — the `/actions` slash command silently downgrades a canvas-built workflow to a flat action list, destroying conditions, delays and branches. From 72eca533fcdccaa93f5bb6fc10bfd94f1729ce1a Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 21:32:59 +0300 Subject: [PATCH 02/19] fix(automation): block saving actions with empty required fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing required action fields were reported at level "warning", and validateWorkflow computes `valid` from errors only — so Save stayed enabled and a rule that can never execute was persisted. It then sat in the rules list looking healthy and silently never fired. Client: promote the required-field branch in validateAction to "error". Genuinely advisory graph issues (empty condition value, unconnected delay, unreachable step) stay warnings so the split keeps meaning something. Server: validateRuleBody checked only that action.type was known plus the webhook URL scheme. Add validateActionConfig, driven by the same ACTION_TYPE_FIELDS descriptors the dashboard renders its form from, so the two cannot drift — and apply it to action *steps* as well, since the bot runs the step graph in preference to the flat action list. The server test file mocked @fluxcore/systems/actions/constants with an empty ACTION_TYPE_FIELDS and two action types, which made the contract invisible and let a test named "validates sendWebhook requires HTTPS URL" pass while posting no webhook at all. Constants are pure data with no I/O — unmock them and give the webhook case a real payload. +21 tests. Full dashboard suite 894/894. Co-Authored-By: Claude Opus 5 (1M context) --- .../automation/lib/workflow-validation.ts | 8 +- .../src/server/features/actions/routes.ts | 88 ++++++++--- .../automation/workflow-validation.test.ts | 114 +++++++++++++- .../server/features/actions/actions.test.ts | 139 ++++++++++++++++-- 4 files changed, 318 insertions(+), 31 deletions(-) diff --git a/apps/dashboard/src/client/features/automation/lib/workflow-validation.ts b/apps/dashboard/src/client/features/automation/lib/workflow-validation.ts index 5d104088..0b4e9417 100644 --- a/apps/dashboard/src/client/features/automation/lib/workflow-validation.ts +++ b/apps/dashboard/src/client/features/automation/lib/workflow-validation.ts @@ -209,9 +209,15 @@ function validateAction( const value = getNestedValue(action, field.key); if (value === undefined || value === null || value === "") { + // An action missing a required field cannot execute — the bot has no + // channel to post to, no role to add. Saving it produces a rule that + // looks healthy in the list and silently never fires, so this is an + // error (blocks Save), not advice. Genuinely advisory graph issues + // (empty condition value, unconnected delay, unreachable step) stay + // warnings so the error/warning split keeps meaning something. issues.push({ nodeId, - level: "warning", + level: "error", message: t("validation.fieldRequired", { label, field: field.label }), }); } diff --git a/apps/dashboard/src/server/features/actions/routes.ts b/apps/dashboard/src/server/features/actions/routes.ts index 4d711236..365419cb 100644 --- a/apps/dashboard/src/server/features/actions/routes.ts +++ b/apps/dashboard/src/server/features/actions/routes.ts @@ -47,6 +47,63 @@ interface RuleRequestBody { enabled?: boolean; } +/** Walks a dotted descriptor key (e.g. `webhook.url`) into an action config. */ +function getNestedValue(obj: Record, path: string): unknown { + let current: unknown = obj; + for (const part of path.split(".")) { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +/** + * Checks one action against ACTION_TYPE_FIELDS — the same descriptor list the + * dashboard renders its form from, so the client and the API cannot drift. + * + * An action missing a required field can never execute: the bot has no channel + * to post to, no role to add. Letting it through produces a rule that looks + * healthy in the list and silently never fires. The client blocks this too, but + * the client is not the gate — `rules.tsx` re-POSTs stored action lists on the + * Undo-delete and Duplicate paths without revalidating, and the API is reachable + * by any script. + * + * Returns an error string, or null when the action is valid. + */ +function validateActionConfig(action: { type: string; [key: string]: unknown }): string | null { + if (!validActionTypes.has(action.type)) { + return `Invalid action type: ${action.type}`; + } + + for (const field of ACTION_TYPE_FIELDS[action.type as ActionType] ?? []) { + if (!field.required) continue; + const value = getNestedValue(action, field.key); + if ( + value === undefined || + value === null || + (typeof value === "string" && value.trim() === "") + ) { + return `${action.type}: ${field.label} is required`; + } + } + + if (action.type === "sendWebhook") { + const webhook = action.webhook as { url?: string } | undefined; + try { + const url = new URL(webhook!.url!); + if (url.protocol !== "https:") { + return "Webhook URL must use HTTPS"; + } + } catch { + return "Invalid webhook URL"; + } + } + + return null; +} + /** * Validates rule request body fields shared between create and update. * Returns an error string if validation fails, or null if valid. @@ -83,23 +140,8 @@ function validateRuleBody( return `Max ${MAX_ACTIONS_PER_RULE} actions per rule`; } for (const action of body.actions) { - if (!validActionTypes.has(action.type)) { - return `Invalid action type: ${action.type}`; - } - if (action.type === "sendWebhook") { - const webhook = action.webhook as { url?: string } | undefined; - if (!webhook?.url) { - return "sendWebhook requires a webhook URL"; - } - try { - const url = new URL(webhook.url); - if (url.protocol !== "https:") { - return "Webhook URL must use HTTPS"; - } - } catch { - return "Invalid webhook URL"; - } - } + const actionError = validateActionConfig(action); + if (actionError) return actionError; } } @@ -111,6 +153,18 @@ function validateRuleBody( if (conditionCount > 3) { return "Max 3 condition steps per rule"; } + // The bot runs the step graph in preference to the flat action list + // (executor.ts), so validating only `actions` would leave the path that + // actually executes unchecked. + for (const step of body.steps) { + if (step.type !== "action") continue; + const action = step.action as { type: string; [key: string]: unknown } | undefined; + if (!action || typeof action.type !== "string") { + return `Step ${step.id} is missing an action`; + } + const actionError = validateActionConfig(action); + if (actionError) return actionError; + } } return null; diff --git a/apps/dashboard/tests/client/features/automation/workflow-validation.test.ts b/apps/dashboard/tests/client/features/automation/workflow-validation.test.ts index c3f80edb..dbfe0ce9 100644 --- a/apps/dashboard/tests/client/features/automation/workflow-validation.test.ts +++ b/apps/dashboard/tests/client/features/automation/workflow-validation.test.ts @@ -1,11 +1,39 @@ import { describe, it, expect } from "vitest"; import { validateWorkflow } from "../../../../src/client/features/automation/lib/workflow-validation"; -import type { RuleStep } from "../../../../src/client/shared/lib/schemas"; +import type { Constants, RuleStep } from "../../../../src/client/shared/lib/schemas"; // validateWorkflow takes the structural TranslateFn, so a plain passthrough // needs no cast. const t = (key: string) => key; +/** + * Minimal constants carrying only what validateAction reads — + * `actionTypeFields`. Mirrors ACTION_TYPE_FIELDS in + * packages/systems/src/actions/constants.ts for the action types under test. + */ +const constants: Constants = { + eventTypes: { memberJoin: { label: "Member Join", description: "" } }, + actionTypes: { + sendMessage: { label: "Send Message", description: "" }, + addRole: { label: "Add Role", description: "" }, + sendWebhook: { label: "Send Webhook", description: "" }, + }, + maxActionsPerRule: 5, + actionTypeFields: { + sendMessage: [ + { key: "channelId", label: "Channel", type: "channel", required: true }, + { key: "message", label: "Message", type: "textarea", required: true }, + ], + addRole: [{ key: "roleId", label: "Role", type: "role", required: true }], + sendWebhook: [ + { key: "webhook.url", label: "Webhook URL", type: "text", required: true }, + { key: "webhook.method", label: "HTTP Method", type: "select" }, + ], + }, + eventTypeVariables: { memberJoin: ["{user}"] }, + templateVariables: { "{user}": "User mention" }, +}; + const graph: RuleStep[] = [ { id: "step_0", type: "action", action: { type: "sendMessage", message: "hi" }, next: "step_1" }, { id: "step_1", type: "condition", condition: { field: "channelId", operator: "equals", value: "9" }, thenNext: "step_2", elseNext: null }, @@ -37,3 +65,87 @@ describe("validateWorkflow — entry-less step graph", () => { expect(result.issues.some((i) => i.message === "validation.noEntryStep")).toBe(false); }); }); + +describe("validateWorkflow — required action fields block save", () => { + it.each([ + ["sendMessage with no channel and no message", { type: "sendMessage" }], + ["sendMessage with a channel but no message", { type: "sendMessage", channelId: "1" }], + ["addRole with no role", { type: "addRole" }], + ["sendWebhook with no url", { type: "sendWebhook", webhook: { url: "" } }], + ])("rejects %s", (_label, action) => { + const result = validateWorkflow("memberJoin", [action], "Test Rule", constants, t, undefined, undefined); + + expect(result.valid).toBe(false); + expect( + result.issues.some( + (i) => i.level === "error" && i.message === "validation.fieldRequired", + ), + ).toBe(true); + }); + + it("anchors the error to the action's node so the canvas can highlight it", () => { + const result = validateWorkflow("memberJoin", [{ type: "addRole" }], "Test Rule", constants, t); + + expect(result.issues).toContainEqual({ + nodeId: "action-0", + level: "error", + message: "validation.fieldRequired", + }); + }); + + it("accepts an action once every required field is filled", () => { + const result = validateWorkflow( + "memberJoin", + [{ type: "sendMessage", channelId: "123", message: "hi" }], + "Test Rule", + constants, + t, + ); + + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("ignores optional fields left empty", () => { + const result = validateWorkflow( + "memberJoin", + [{ type: "sendWebhook", webhook: { url: "https://example.com" } }], + "Test Rule", + constants, + t, + ); + + expect(result.valid).toBe(true); + }); + + it("blocks save for an action step inside a graph, not just a linear action", () => { + const steps: RuleStep[] = [ + { id: "step_0", type: "action", action: { type: "addRole" }, next: null }, + ]; + const result = validateWorkflow("memberJoin", [], "Test Rule", constants, t, steps, "step_0"); + + expect(result.valid).toBe(false); + expect(result.issues).toContainEqual({ + nodeId: "step-step_0", + level: "error", + message: "validation.fieldRequired", + }); + }); + + it("keeps advisory graph issues as warnings so the error/warning split stays meaningful", () => { + const steps: RuleStep[] = [ + { id: "step_0", type: "action", action: { type: "addRole", roleId: "1" }, next: "step_1" }, + { + id: "step_1", + type: "condition", + condition: { field: "channelId", operator: "equals", value: "" }, + thenNext: null, + elseNext: null, + }, + ]; + const result = validateWorkflow("memberJoin", [], "Test Rule", constants, t, steps, "step_0"); + + expect(result.valid).toBe(true); + expect(result.issues.every((i) => i.level === "warning")).toBe(true); + }); +}); diff --git a/apps/dashboard/tests/server/features/actions/actions.test.ts b/apps/dashboard/tests/server/features/actions/actions.test.ts index 76844d16..76eabf75 100644 --- a/apps/dashboard/tests/server/features/actions/actions.test.ts +++ b/apps/dashboard/tests/server/features/actions/actions.test.ts @@ -76,14 +76,11 @@ vi.mock("@fluxcore/systems/actions/config", () => ({ setGuildSettings: (...args: unknown[]) => mockSetGuildSettings(...args), })); -vi.mock("@fluxcore/systems/actions/constants", () => ({ - EVENT_TYPES: { memberJoin: { label: "Member Join" }, memberLeave: { label: "Member Leave" } }, - ACTION_TYPES: { sendMessage: { label: "Send Message" }, addRole: { label: "Add Role" } }, - MAX_ACTIONS_PER_RULE: 5, - ACTION_TYPE_FIELDS: {}, - EVENT_TYPE_VARIABLES: {}, - TEMPLATE_VARIABLES: [], -})); +// `@fluxcore/systems/actions/constants` is deliberately NOT mocked. It is pure +// data with no I/O, and it is the single source of truth the route validates +// against — a stubbed ACTION_TYPE_FIELDS would make these tests assert a +// fiction (it previously did: the required-field contract was invisible, and +// half the action types read as "invalid action type"). vi.mock("@fluxcore/utils", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -219,14 +216,131 @@ describe("action routes", () => { payload: { name: "test", eventType: "memberJoin", - actions: [{ type: "sendMessage" }], + actions: [{ type: "sendMessage", channelId: "1", message: "hi" }], }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toContain("Rule limit reached"); }); - it("validates sendWebhook requires HTTPS URL", async () => { + it("rejects sendWebhook over plain HTTP", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { + name: "test", + eventType: "memberJoin", + actions: [{ type: "sendWebhook", webhook: { url: "http://example.com/hook" } }], + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("HTTPS"); + }); + + it("accepts sendWebhook over HTTPS", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { + name: "test", + eventType: "memberJoin", + actions: [{ type: "sendWebhook", webhook: { url: "https://example.com/hook" } }], + }, + }); + expect(res.statusCode).toBe(201); + }); + }); + + // The client's Save button is only one gate, and it is not the one that + // matters: the Undo-delete and Duplicate paths in rules.tsx re-POST a stored + // action list without revalidating, and the API is public to any script. A + // rule missing a required field can never execute, so the server must be the + // one that refuses it. + describe("POST /api/guilds/:guildId/actions/rules — required action fields", () => { + it.each([ + ["sendMessage without a channel", { type: "sendMessage", message: "hi" }, "Channel"], + ["sendMessage without a message", { type: "sendMessage", channelId: "1" }, "Message"], + ["addRole without a role", { type: "addRole" }, "Role"], + ["setNickname without a nickname", { type: "setNickname" }, "Nickname"], + ["createThread without a thread name", { type: "createThread", channelId: "1" }, "Thread Name"], + ["addReaction without an emoji", { type: "addReaction" }, "Emoji"], + ])("rejects %s", async (_label, action, expectedField) => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { name: "test", eventType: "memberJoin", actions: [action] }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain(expectedField); + }); + + it("accepts an action with every required field filled", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { + name: "test", + eventType: "memberJoin", + actions: [{ type: "sendMessage", channelId: "1", message: "hi" }], + }, + }); + expect(res.statusCode).toBe(201); + }); + + it("treats a whitespace-only required field as missing", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { + name: "test", + eventType: "memberJoin", + actions: [{ type: "sendMessage", channelId: "1", message: " " }], + }, + }); + expect(res.statusCode).toBe(400); + }); + + // The bot executes `steps` in preference to `actions` (executor.ts:274), + // so validating only `actions` leaves the path that actually runs unchecked. + it("rejects an action step whose required field is empty", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { + name: "test", + eventType: "memberJoin", + actions: [{ type: "sendMessage", channelId: "1", message: "hi" }], + entryStepId: "step_0", + steps: [{ id: "step_0", type: "action", action: { type: "addRole" }, next: null }], + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("Role"); + }); + + it("rejects an unknown action type inside a step", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/actions/rules", + cookies: { session: app.signCookie("valid") }, + payload: { + name: "test", + eventType: "memberJoin", + actions: [{ type: "sendMessage", channelId: "1", message: "hi" }], + entryStepId: "step_0", + steps: [{ id: "step_0", type: "action", action: { type: "nope" }, next: null }], + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("accepts a valid step graph", async () => { const res = await app.inject({ method: "POST", url: "/api/guilds/guild-1/actions/rules", @@ -234,10 +348,11 @@ describe("action routes", () => { payload: { name: "test", eventType: "memberJoin", - actions: [{ type: "sendMessage" }, { type: "sendMessage" }], + actions: [{ type: "addRole", roleId: "7" }], + entryStepId: "step_0", + steps: [{ id: "step_0", type: "action", action: { type: "addRole", roleId: "7" }, next: null }], }, }); - // Should pass as sendMessage doesn't need webhook expect(res.statusCode).toBe(201); }); }); From 5ce9ad3f637bfb40c0eebeaebc00850b5ff80b24 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 21:43:59 +0300 Subject: [PATCH 03/19] fix(automation): scope action variables to the trigger, restore them in step mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action panel's Variables tab rendered every token in the system (Object.keys(constants.templateVariables)) regardless of the selected trigger, so it contradicted both the autocomplete beside it — which was correctly scoped via buildAutomationVariables — and the editor's own unknown-token validator. A memberJoin rule offered {message.content} and {ban.reason}, which the bot renders as "Unknown". Worse, the step-mode action editor had no Variables tab and no message preview at all. Adding a single condition or delay converts the rule to step mode, which silently stripped both from every action in it. Extract ActionSettings (type picker + fields + preview) and ActionTabs (the Settings/Variables shell, scoped to the trigger) and use them from both ActionPanel and StepPanel, so the two editors cannot diverge again. Also withdraws finding B3 from the audit doc: VARIABLE_FIELD_KEYS turns out to already cover every template-bearing field — the three outside it (webhook.url, webhook.headers, emoji) should not accept templates. +4 tests (new NodeDetailPanel.test.tsx). Full dashboard suite 898/898. Co-Authored-By: Claude Opus 5 (1M context) --- .../automation/workflow/NodeDetailPanel.tsx | 313 ++++++++++-------- .../automation/NodeDetailPanel.test.tsx | 173 ++++++++++ .../2026-07-27-automation-rules-audit.md | 2 +- 3 files changed, 350 insertions(+), 138 deletions(-) create mode 100644 apps/dashboard/tests/client/features/automation/NodeDetailPanel.test.tsx diff --git a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx index 8aa2934b..7afd9798 100644 --- a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx +++ b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx @@ -5,6 +5,7 @@ import { useChannels } from "../../../shared/hooks/useChannels"; import { useRoles } from "../../../shared/hooks/useRoles"; import { ActionFields } from "../components/ActionFields"; import { buildAutomationVariables, DiscordMessagePreview, usePreviewContext } from "../../../shared/ui/variable-field"; +import type { VariableDescriptor } from "../../../shared/ui/variable-field"; import { Button } from "../../../shared/ui/button"; import { Label } from "../../../shared/ui/label"; import { Input } from "../../../shared/ui/input"; @@ -297,39 +298,102 @@ function TriggerPanel({ ); } -function ActionPanel({ - index, +/** + * The action type picker, its fields and its message preview. + * + * Shared by the linear ActionPanel and the step-mode StepPanel so the two can + * never diverge again — step mode previously rendered a stripped-down copy with + * no preview, so adding a single condition or delay to a rule silently removed + * the preview from every action in it. + */ +function ActionSettings({ action, constants, guildId, - eventType, - totalActions, - onActionChange, - onActionRemove, - onActionMove, - canRemove, -}: ActionPanelProps) { + variables, + onTypeChange, + onFieldChange, +}: { + action: ActionConfig; + constants: Constants; + guildId: string; + variables: VariableDescriptor[]; + onTypeChange: (type: string) => void; + onFieldChange: (key: string, value: unknown) => void; +}) { const { t } = useTranslation(["rules", "common"]); const { data: channels = [] } = useChannels(guildId); const { data: roles = [] } = useRoles(guildId); + const real = usePreviewContext(guildId); const fields: ActionFieldDescriptor[] = constants.actionTypeFields[action.type] ?? []; - const variables = buildAutomationVariables(constants, eventType); - const real = usePreviewContext(guildId); - const handleTypeChange = (newType: string) => { - onActionChange(index, { type: newType }); - }; + return ( + <> +
+ + v && onTypeChange(v)} + placeholder={t("panel.selectAction")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> +
- const handleFieldChange = (key: string, value: unknown) => { - const updated = setNestedValue( - action as unknown as Record, - key, - value, - ) as unknown as ActionConfig; - onActionChange(index, updated); - }; + {action.type && fields.length > 0 && ( + } + onChange={onFieldChange} + channels={channels} + roles={roles} + variables={variables} + /> + )} + {(action.type === "sendMessage" || action.type === "sendDM") && ( +
+ +
+ )} + {action.type === "sendEmbed" && ( +
+ +
+ )} + + ); +} + +/** Settings/Variables tab shell shared by both action editors. */ +function ActionTabs({ + eventType, + constants, + children, +}: { + eventType: string; + constants: Constants; + children: React.ReactNode; +}) { + const { t } = useTranslation(["rules", "common"]); return ( @@ -344,94 +408,16 @@ function ActionPanel({ -
-
- - v && handleTypeChange(v)} - placeholder={t("panel.selectAction")} - searchPlaceholder={t("panel.search")} - noResultsLabel={t("panel.noResults")} - /> -
- - {action.type && fields.length > 0 && ( - } - onChange={handleFieldChange} - channels={channels} - roles={roles} - variables={variables} - /> - )} - - {(action.type === "sendMessage" || action.type === "sendDM") && ( -
- -
- )} - {action.type === "sendEmbed" && ( -
- -
- )} - - {totalActions > 1 && ( -
- - -
- )} - - {canRemove && ( - onActionRemove(index)} - /> - )} -
+
{children}
+ {/* Scoped to the selected trigger, exactly like the trigger panel's own + tab. Listing every token in the system contradicted both the + autocomplete beside it and the editor's unknown-token validator, + and invited templates the bot renders as "Unknown". */} @@ -439,6 +425,80 @@ function ActionPanel({ ); } +function ActionPanel({ + index, + action, + constants, + guildId, + eventType, + totalActions, + onActionChange, + onActionRemove, + onActionMove, + canRemove, +}: ActionPanelProps) { + const { t } = useTranslation(["rules", "common"]); + const variables = buildAutomationVariables(constants, eventType); + + const handleTypeChange = (newType: string) => { + onActionChange(index, { type: newType }); + }; + + const handleFieldChange = (key: string, value: unknown) => { + const updated = setNestedValue( + action as unknown as Record, + key, + value, + ) as unknown as ActionConfig; + onActionChange(index, updated); + }; + + return ( + + + + {totalActions > 1 && ( +
+ + +
+ )} + + {canRemove && ( + onActionRemove(index)} + /> + )} +
+ ); +} + const CONDITION_FIELDS: Array<{ value: string; labelKey: string }> = [ { value: "channelId", labelKey: "conditionFields.channelId" }, { value: "channelName", labelKey: "conditionFields.channelName" }, @@ -535,15 +595,11 @@ function StepPanel({ onStepRemove, }: StepPanelProps) { const { t } = useTranslation(["rules", "common"]); - const { data: channels = [] } = useChannels(guildId); - const { data: roles = [] } = useRoles(guildId); const variables = buildAutomationVariables(constants, eventType); const step = steps.find((s) => s.id === stepId); if (!step) return

{t("panel.stepNotFound")}

; if (step.type === "action") { - const fields = constants.actionTypeFields[step.action.type] ?? []; - const handleTypeChange = (newType: string) => { onStepChange(stepId, { ...step, action: { type: newType } }); }; @@ -558,32 +614,15 @@ function StepPanel({ }; return ( -
-
- - v && handleTypeChange(v)} - placeholder={t("panel.selectAction")} - searchPlaceholder={t("panel.search")} - noResultsLabel={t("panel.noResults")} - /> -
- - {step.action.type && fields.length > 0 && ( - } - onChange={handleFieldChange} - channels={channels} - roles={roles} - variables={variables} - /> - )} + +
{t("panel.connections")} @@ -601,7 +640,7 @@ function StepPanel({ label={t("panel.removeStep")} onConfirm={() => onStepRemove(stepId)} /> -
+
); } diff --git a/apps/dashboard/tests/client/features/automation/NodeDetailPanel.test.tsx b/apps/dashboard/tests/client/features/automation/NodeDetailPanel.test.tsx new file mode 100644 index 00000000..709a344b --- /dev/null +++ b/apps/dashboard/tests/client/features/automation/NodeDetailPanel.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NodeDetailPanel } from "../../../../src/client/features/automation/workflow/NodeDetailPanel"; +import type { Constants, RuleStep } from "../../../../src/client/shared/lib/schemas"; + +// Stable passthrough translator — see the note in WorkflowEditor.test.tsx for +// why identity stability matters in this tree. +function translate(key: string, opts?: Record): string { + return opts ? `${key}:${JSON.stringify(opts)}` : key; +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: translate, i18n: { dir: () => "ltr" } }), +})); + +vi.mock("../../../../src/client/shared/hooks/useChannels", () => ({ + useChannels: () => ({ data: [{ id: "c1", name: "general", type: 0 }] }), +})); +vi.mock("../../../../src/client/shared/hooks/useRoles", () => ({ + useRoles: () => ({ data: [{ id: "r1", name: "Members" }] }), +})); + +// usePreviewContext reads these two react-query hooks. `{user}` carries a +// realKey, so the preview resolves it from buildRealData rather than the +// descriptor's sample — with no session that yields "@User". +vi.mock("../../../../src/client/shared/hooks/useAuth", () => ({ + useAuth: () => ({ data: undefined }), +})); +vi.mock("../../../../src/client/shared/hooks/useGuilds", () => ({ + useGuilds: () => ({ data: [] }), +})); + +// Radix ScrollArea measures with ResizeObserver, absent in jsdom. +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +/** + * memberJoin carries no message tokens; messageCreated does. The gap between + * the two is what every assertion below turns on. + */ +const constants: Constants = { + eventTypes: { + memberJoin: { label: "Member Join", description: "" }, + messageCreated: { label: "Message Sent", description: "" }, + }, + actionTypes: { sendMessage: { label: "Send Message", description: "" } }, + maxActionsPerRule: 5, + actionTypeFields: { + sendMessage: [ + { key: "channelId", label: "Channel", type: "channel", required: true }, + { key: "message", label: "Message", type: "textarea", required: true }, + ], + }, + eventTypeVariables: { + memberJoin: ["{user}", "{guild}"], + messageCreated: ["{user}", "{guild}", "{message.content}"], + }, + templateVariables: { + "{user}": "User mention", + "{guild}": "Server name", + "{message.content}": "Message content", + "{ban.reason}": "Ban reason", + }, +}; + +const noop = () => {}; + +async function openVariablesTab() { + await userEvent.click(await screen.findByRole("tab", { name: /panel\.variables/ })); +} + +describe("NodeDetailPanel — action variables are scoped to the trigger", () => { + it("lists only the trigger's variables in a linear action's Variables tab", async () => { + render( + , + ); + + await openVariablesTab(); + + expect(screen.getByText("{user}")).toBeInTheDocument(); + expect(screen.getByText("{guild}")).toBeInTheDocument(); + // memberJoin has no message and no ban context — offering these tokens + // tells the user to write a template the bot will render as "Unknown". + expect(screen.queryByText("{message.content}")).not.toBeInTheDocument(); + expect(screen.queryByText("{ban.reason}")).not.toBeInTheDocument(); + }); + + it("widens the list when the trigger provides more", async () => { + render( + , + ); + + await openVariablesTab(); + + expect(screen.getByText("{message.content}")).toBeInTheDocument(); + expect(screen.queryByText("{ban.reason}")).not.toBeInTheDocument(); + }); +}); + +describe("NodeDetailPanel — step-mode actions keep variables and preview", () => { + const steps: RuleStep[] = [ + { + id: "step_0", + type: "action", + action: { type: "sendMessage", channelId: "c1", message: "hi {user}" }, + next: null, + }, + ]; + + function renderStep() { + return render( + , + ); + } + + // Adding a single condition or delay converts the whole rule to step mode. + // Before this, that silently stripped the variable reference and the message + // preview from every action in the rule. + it("offers a Variables tab scoped to the trigger", async () => { + renderStep(); + + await openVariablesTab(); + + expect(screen.getByText("{user}")).toBeInTheDocument(); + expect(screen.queryByText("{message.content}")).not.toBeInTheDocument(); + }); + + it("renders the Discord message preview for a sendMessage step", () => { + renderStep(); + + expect(screen.getByText("hi @User")).toBeInTheDocument(); + }); +}); diff --git a/docs/superpowers/specs/2026-07-27-automation-rules-audit.md b/docs/superpowers/specs/2026-07-27-automation-rules-audit.md index d734b8fd..25f6b4c9 100644 --- a/docs/superpowers/specs/2026-07-27-automation-rules-audit.md +++ b/docs/superpowers/specs/2026-07-27-automation-rules-audit.md @@ -31,7 +31,7 @@ The three reported problems are all real and all have narrow, well-understood ro 1. The action panel's **Variables tab** ignores the trigger entirely: [`NodeDetailPanel.tsx:432-437`](../../apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx#L432-L437) renders `Object.keys(constants.templateVariables)` — all 27 tokens — while the trigger panel's tab renders the scoped set. So the reference list contradicts the autocomplete, and contradicts the editor's own unknown-token validator. 2. The **step-mode action editor has no Variables tab and no message preview at all** ([`NodeDetailPanel.tsx:544-605`](../../apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx#L544-L605)). Adding one condition or delay converts the rule to step mode and strips both from every action in it. -3. **Some fields never get the editor.** `ActionFields.tsx:19-27` gates the `VariableEditor` on a hardcoded `VARIABLE_FIELD_KEYS` set, so template-bearing fields outside it fall back to a plain input with no autocomplete and no highlighting. +3. ~~Some fields never get the editor.~~ **Withdrawn on implementation.** `ActionFields.tsx:19-27` does gate the `VariableEditor` on a hardcoded `VARIABLE_FIELD_KEYS` set, but enumerating every `text`/`textarea` field in `ACTION_TYPE_FIELDS` shows the set is already complete: the only three outside it are `webhook.url`, `webhook.headers` (JSON) and `emoji` — none of which should accept templates. No change needed. Underneath, `EVENT_TYPE_VARIABLES` also **over-promises**: it advertises `{channel*}` on member/ban/role events and `{user*}` on channel events that `eventBridge` never populates, so the preview renders `#general`/`@Ada` where the bot will post `Unknown Channel`/`Unknown User`. From e12a064c7fea372fb2b75b5357063bafd93db54c Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 21:54:19 +0300 Subject: [PATCH 04/19] fix(automation): make trigger filters fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters were guarded on the presence of their own datum (`conditions.excludeRoleIds?.length && context.member`), so a filter the event context could not answer was silently *skipped*. The rule then fired on exactly the users and channels it had been configured to exclude, while the dashboard counted the filter as active: "Exclude -> Roles: @Staff" on Member Banned announced every staff ban, because a ban context carries no member object at all. An exclusion that cannot be evaluated is not permission to proceed. Both directions now fail closed: a configured filter whose datum is missing stops the rule. That makes it important not to offer a filter a trigger can never satisfy, so add EVENT_CONDITION_SUPPORT — the per-event map of which of channelId / member / userId the event bridge actually populates — as the single source of truth for the editor to read next. +8 executor tests, +9 support-matrix tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/automation/system/executor.ts | 37 ++++-- .../automation/system/executor.test.ts | 116 ++++++++++++++++++ packages/systems/src/actions/constants.ts | 63 ++++++++++ .../unit/actions-condition-support.test.ts | 80 ++++++++++++ 4 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 packages/systems/tests/unit/actions-condition-support.test.ts diff --git a/apps/bot/src/features/automation/system/executor.ts b/apps/bot/src/features/automation/system/executor.ts index 6857738a..3e50a275 100644 --- a/apps/bot/src/features/automation/system/executor.ts +++ b/apps/bot/src/features/automation/system/executor.ts @@ -46,32 +46,53 @@ function checkRateLimit(guildId: string, eventType: string): boolean { // --- Condition matching --- +/** + * Trigger filters FAIL CLOSED: a configured filter the event context cannot + * answer means the rule does not fire. + * + * These guards used to read `conditions.excludeRoleIds?.length && + * context.member`, which silently *skipped* an unanswerable filter. The rule + * then fired on exactly the users and channels it was configured to exclude — + * "exclude @Staff" on a Member Banned rule announced every staff ban, because + * a ban context carries no member — while the dashboard counted the filter as + * active. An exclusion that cannot be evaluated is not a permission to + * proceed; it is a reason to stop. + * + * The editor is event-aware (EVENT_CONDITION_SUPPORT) so new rules cannot be + * built with a filter their trigger can never evaluate. + */ function matchesConditions( conditions: ActionConditions, context: EventContext, ): boolean { - // Include filters: if specified, context must match - if (conditions.channelIds?.length && context.channelId) { + // Include filters: the context must carry the datum AND match it. + if (conditions.channelIds?.length) { + if (!context.channelId) return false; if (!conditions.channelIds.includes(context.channelId)) return false; } - if (conditions.userIds?.length && context.userId) { + if (conditions.userIds?.length) { + if (!context.userId) return false; if (!conditions.userIds.includes(context.userId)) return false; } - if (conditions.roleIds?.length && context.member) { + if (conditions.roleIds?.length) { + if (!context.member) return false; const hasMatchingRole = conditions.roleIds.some((id) => context.member!.roles.cache.has(id), ); if (!hasMatchingRole) return false; } - // Exclude filters: if matched, skip - if (conditions.excludeChannelIds?.length && context.channelId) { + // Exclude filters: an unanswerable exclusion cannot be cleared, so it blocks. + if (conditions.excludeChannelIds?.length) { + if (!context.channelId) return false; if (conditions.excludeChannelIds.includes(context.channelId)) return false; } - if (conditions.excludeUserIds?.length && context.userId) { + if (conditions.excludeUserIds?.length) { + if (!context.userId) return false; if (conditions.excludeUserIds.includes(context.userId)) return false; } - if (conditions.excludeRoleIds?.length && context.member) { + if (conditions.excludeRoleIds?.length) { + if (!context.member) return false; const hasExcludedRole = conditions.excludeRoleIds.some((id) => context.member!.roles.cache.has(id), ); diff --git a/apps/bot/tests/features/automation/system/executor.test.ts b/apps/bot/tests/features/automation/system/executor.test.ts index 49f4567f..27875129 100644 --- a/apps/bot/tests/features/automation/system/executor.test.ts +++ b/apps/bot/tests/features/automation/system/executor.test.ts @@ -214,3 +214,119 @@ describe("action executor - processEvent", () => { expect(mockSendMessageExecutor).toHaveBeenCalledTimes(2); }); }); + +/** + * Filters used to be guarded on the presence of their own datum + * (`conditions.excludeRoleIds?.length && context.member`), so a filter the + * event context could not answer was skipped rather than failed. The rule then + * fired on exactly the users and channels it had been configured to skip, while + * the dashboard showed the filter as active. Every case below is a rule that + * must NOT fire. + */ +describe("action executor - filters fail closed", () => { + const memberlessContext = { + eventType: "memberBanned" as const, + guildId: "guild-123", + guildName: "Test Guild", + userId: "user-456", + userName: "TestUser", + userTag: "TestUser#0001", + userMention: "<@user-456>", + memberCount: 100, + timestamp: new Date().toISOString(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetGuildSettingsOrDefault.mockReturnValue({ + globalEnabled: true, + maxRules: 25, + logChannelId: null, + }); + }); + + function ruleWith(conditions: Record) { + return [ + { name: "filtered", enabled: true, conditions, actions: [{ type: "sendMessage" }] }, + ]; + } + + it("does not fire an exclude-role filter when the event carries no member", async () => { + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ excludeRoleIds: ["staff"] })); + + await processEvent({} as never, memberlessContext); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("does not fire an include-role filter when the event carries no member", async () => { + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ roleIds: ["verified"] })); + + await processEvent({} as never, memberlessContext); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("does not fire a channel filter when the event carries no channel", async () => { + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ channelIds: ["general"] })); + + await processEvent({} as never, memberlessContext); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("does not fire an exclude-channel filter when the event carries no channel", async () => { + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ excludeChannelIds: ["general"] })); + + await processEvent({} as never, memberlessContext); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("does not fire a user filter when the event carries no user", async () => { + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ userIds: ["someone"] })); + + await processEvent({} as never, { + eventType: "channelCreated" as const, + guildId: "guild-123", + guildName: "Test Guild", + channelId: "channel-789", + memberCount: 100, + timestamp: new Date().toISOString(), + }); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("still fires when the filter is satisfiable and matches", async () => { + const withMember = { + ...memberlessContext, + member: { roles: { cache: new Map([["verified", {}]]) } }, + }; + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ roleIds: ["verified"] })); + + await processEvent({} as never, withMember as never); + + expect(mockSendMessageExecutor).toHaveBeenCalled(); + }); + + it("still skips when the filter is satisfiable and does not match", async () => { + const withMember = { + ...memberlessContext, + member: { roles: { cache: new Map([["other", {}]]) } }, + }; + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ roleIds: ["verified"] })); + + await processEvent({} as never, withMember as never); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("leaves unfiltered rules alone", async () => { + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({})); + + await processEvent({} as never, memberlessContext); + + expect(mockSendMessageExecutor).toHaveBeenCalled(); + }); +}); diff --git a/packages/systems/src/actions/constants.ts b/packages/systems/src/actions/constants.ts index 642a83d2..af5be6ec 100644 --- a/packages/systems/src/actions/constants.ts +++ b/packages/systems/src/actions/constants.ts @@ -262,6 +262,69 @@ export const CONDITION_TYPES = [ export type ConditionType = (typeof CONDITION_TYPES)[number]; +/** The three data a trigger filter can key on. */ +export type ConditionSubject = "channel" | "role" | "user"; + +/** + * Which filter subjects each event type can actually be filtered by — i.e. + * which of `channelId`, `member` and `userId` the bot populates on that + * event's EventContext (see apps/bot/.../eventBridge.ts). + * + * Trigger filters fail closed: a configured filter the context cannot answer + * stops the rule from firing. So offering a filter the trigger can never + * satisfy does not merely do nothing — it silently disables the rule. The + * dashboard reads this map to only offer filters that can work, and to flag + * rules that already carry one that cannot. + * + * - `channel` — context.channelId is set + * - `role` — context.member is set (role filters read member.roles) + * - `user` — context.userId is set + */ +export const EVENT_CONDITION_SUPPORT: Record = { + // buildMemberContext — member present unless the gateway sent a partial + memberJoin: ["user", "role"], + memberLeave: ["user", "role"], + nicknameChanged: ["user", "role"], + memberTimeout: ["user", "role"], + boostStart: ["user", "role"], + boostEnd: ["user", "role"], + + // buildBanContext — the user is no longer a member, so there is no member + // object to read roles from, on either ban or unban + memberBanned: ["user"], + memberUnbanned: ["user"], + + // buildMessageContext / buildReactionContext — full context + messageCreated: ["user", "role", "channel"], + messageDeleted: ["user", "role", "channel"], + reactionAdded: ["user", "role", "channel"], + reactionRemoved: ["user", "role", "channel"], + + // buildRoleContext — member present, but no channel + roleAdded: ["user", "role"], + roleRemoved: ["user", "role"], + + // buildChannelContext — no acting user is available on the gateway event + channelCreated: ["channel"], + channelDeleted: ["channel"], + + // buildVoiceContext — full context + voiceJoin: ["user", "role", "channel"], + voiceLeave: ["user", "role", "channel"], + + // threadCreate — channelId is the NEW thread, and the owner is a bare id + threadCreated: ["user", "channel"], +}; + +/** Whether `eventType` can be filtered by `subject`. */ +export function supportsCondition( + eventType: string, + subject: ConditionSubject, +): boolean { + const supported = EVENT_CONDITION_SUPPORT[eventType as ActionEventType]; + return supported ? supported.includes(subject) : false; +} + /** * Allowed character set for action rule names. Restricts user-supplied * names so they cannot inject markdown, mention syntax, code fences, or diff --git a/packages/systems/tests/unit/actions-condition-support.test.ts b/packages/systems/tests/unit/actions-condition-support.test.ts new file mode 100644 index 00000000..b802e743 --- /dev/null +++ b/packages/systems/tests/unit/actions-condition-support.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + EVENT_TYPES, + EVENT_CONDITION_SUPPORT, + supportsCondition, + type ConditionSubject, +} from "../../src/actions/constants.js"; + +const SUBJECTS: ConditionSubject[] = ["channel", "role", "user"]; + +describe("EVENT_CONDITION_SUPPORT", () => { + // Trigger filters fail closed, so a filter offered for an event that cannot + // answer it silently disables the rule. A missing entry here would read as + // "supports nothing", which is the safe direction — but it would also strip + // every filter from that trigger in the editor, so the gap must be loud. + it("covers every event type", () => { + for (const eventType of Object.keys(EVENT_TYPES)) { + expect( + EVENT_CONDITION_SUPPORT[eventType as keyof typeof EVENT_CONDITION_SUPPORT], + ).toBeDefined(); + } + }); + + it("declares no unknown event types", () => { + for (const eventType of Object.keys(EVENT_CONDITION_SUPPORT)) { + expect(EVENT_TYPES).toHaveProperty(eventType); + } + }); + + it("only lists known subjects, with no duplicates", () => { + for (const [eventType, subjects] of Object.entries(EVENT_CONDITION_SUPPORT)) { + expect(subjects.length, eventType).toBeGreaterThan(0); + expect(new Set(subjects).size, eventType).toBe(subjects.length); + for (const subject of subjects) { + expect(SUBJECTS, eventType).toContain(subject); + } + } + }); + + // These four are the combinations that were silently broken before filters + // failed closed — they are the reason this map exists. + it("does not offer role filters on ban events (the user is no longer a member)", () => { + expect(supportsCondition("memberBanned", "role")).toBe(false); + expect(supportsCondition("memberUnbanned", "role")).toBe(false); + }); + + it("does not offer channel filters on member events (no channel in context)", () => { + expect(supportsCondition("memberJoin", "channel")).toBe(false); + expect(supportsCondition("memberLeave", "channel")).toBe(false); + }); + + it("does not offer user filters on channel events (no acting user in context)", () => { + expect(supportsCondition("channelCreated", "user")).toBe(false); + expect(supportsCondition("channelDeleted", "user")).toBe(false); + }); + + it("does not offer role filters on threadCreated (the owner is a bare id)", () => { + expect(supportsCondition("threadCreated", "role")).toBe(false); + expect(supportsCondition("threadCreated", "channel")).toBe(true); + }); + + it("offers the full set on message, reaction and voice events", () => { + for (const eventType of [ + "messageCreated", + "messageDeleted", + "reactionAdded", + "reactionRemoved", + "voiceJoin", + "voiceLeave", + ]) { + for (const subject of SUBJECTS) { + expect(supportsCondition(eventType, subject), `${eventType}/${subject}`).toBe(true); + } + } + }); + + it("reports false for an unknown event type rather than throwing", () => { + expect(supportsCondition("notAnEvent", "user")).toBe(false); + }); +}); From 31b19522bf94553175c58f94a2b4096dd30a9974 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 22:04:48 +0300 Subject: [PATCH 05/19] feat(automation): only offer trigger filters the trigger can evaluate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the fail-closed change: now that an unanswerable filter stops a rule from firing, the editor must not let one be built. It reads EVENT_CONDITION_SUPPORT (served on /api/actions/constants) and renders only the filter groups the selected trigger can satisfy — no channel filters on Member Join, no role filters on Member Banned, no user filter on Channel Created. With no trigger picked yet it offers everything, since the user is about to pick one. Rules can still carry a filter from before their trigger was changed, so those are surfaced rather than hidden: a warning naming the stranded filters plus a one-click "remove filters that cannot apply" that clears only the unusable keys. Also gives include and exclude distinct labels ("Only in these channels" vs "Never in these channels"). The two groups previously shared the identical strings "Channels"/"Roles"/"Users", which left a screen-reader user unable to tell them apart. +8 ConditionsEditor tests, +1 constants-endpoint test. Dashboard 907/907, bot automation 32/32, systems 337/337. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ConditionsEditor.tsx | 200 ++++++++++++------ .../automation/workflow/NodeDetailPanel.tsx | 3 + .../src/client/shared/lib/schemas.ts | 9 + .../src/server/features/actions/routes.ts | 5 + .../automation/ConditionsEditor.test.tsx | 154 ++++++++++++++ .../server/features/actions/actions.test.ts | 19 ++ packages/i18n/src/locales/en/rules.json | 10 +- 7 files changed, 339 insertions(+), 61 deletions(-) create mode 100644 apps/dashboard/tests/client/features/automation/ConditionsEditor.test.tsx diff --git a/apps/dashboard/src/client/features/automation/components/ConditionsEditor.tsx b/apps/dashboard/src/client/features/automation/components/ConditionsEditor.tsx index a8c0652b..eb92686b 100644 --- a/apps/dashboard/src/client/features/automation/components/ConditionsEditor.tsx +++ b/apps/dashboard/src/client/features/automation/components/ConditionsEditor.tsx @@ -8,6 +8,18 @@ import { Label } from "../../../shared/ui/label"; import { DiscordMultiSelect } from "../../../shared/ui/discord-multi-select"; import type { ActionConditions } from "../../../shared/lib/schemas"; +/** The three data a trigger filter can key on. Mirrors ConditionSubject. */ +export type ConditionSubject = "channel" | "role" | "user"; + +/** Which condition keys belong to which subject. */ +const SUBJECT_KEYS: Record = { + channel: ["channelIds", "excludeChannelIds"], + role: ["roleIds", "excludeRoleIds"], + user: ["userIds", "excludeUserIds"], +}; + +const ALL_SUBJECTS: ConditionSubject[] = ["channel", "role", "user"]; + interface ConditionsEditorProps { guildId: string; conditions: ActionConditions; @@ -15,6 +27,16 @@ interface ConditionsEditorProps { compact?: boolean; /** Always show expanded, no collapse button */ alwaysExpanded?: boolean; + /** + * Which filter subjects the selected trigger can actually evaluate, from + * EVENT_CONDITION_SUPPORT. Filters fail closed in the bot, so offering one + * the event context can never answer would silently stop the rule from + * firing rather than simply doing nothing. + * + * Undefined means no trigger is chosen yet — offer everything rather than + * an empty panel, since the user is about to pick one. + */ + supported?: ConditionSubject[]; } function ChipList({ @@ -134,12 +156,33 @@ export function ConditionsEditor({ onChange, compact, alwaysExpanded, + supported, }: ConditionsEditorProps) { const { t } = useTranslation("rules"); const update = (patch: Partial) => { onChange({ ...conditions, ...patch }); }; + const subjects = supported ?? ALL_SUBJECTS; + const shows = (subject: ConditionSubject) => subjects.includes(subject); + + // A rule can carry filters from before its trigger was changed. The runtime + // now refuses to fire such a rule, so the editor has to surface them rather + // than just hiding the controls that would reveal them. + const strandedSubjects = ALL_SUBJECTS.filter( + (subject) => + !shows(subject) && + SUBJECT_KEYS[subject].some((key) => (conditions[key]?.length ?? 0) > 0), + ); + + const clearStranded = () => { + const next = { ...conditions }; + for (const subject of strandedSubjects) { + for (const key of SUBJECT_KEYS[subject]) delete next[key]; + } + onChange(next); + }; + const hasAnyConditions = (conditions.channelIds?.length ?? 0) + (conditions.roleIds?.length ?? 0) + @@ -204,38 +247,69 @@ export function ConditionsEditor({ {t("conditions.description")}

+ {strandedSubjects.length > 0 && ( +
+

+ + {t("conditions.unsupportedWarning", { + filters: strandedSubjects + .map((s) => t(`conditions.${s === "channel" ? "channels" : s === "role" ? "roles" : "users"}`)) + .join(", "), + })} +

+ +
+ )} + {/* Include filters */}
{t("conditions.include")} - update({ channelIds: ids })} - placeholder={t("conditions.addChannel")} - label={t("conditions.channels")} - /> - update({ roleIds: ids })} - placeholder={t("conditions.addRole")} - label={t("conditions.roles")} - /> - { - const current = conditions.userIds ?? []; - if (!current.includes(id)) update({ userIds: [...current, id] }); - }} - onRemove={(id) => - update({ userIds: (conditions.userIds ?? []).filter((v) => v !== id) }) - } - /> + {shows("channel") && ( + update({ channelIds: ids })} + placeholder={t("conditions.addChannel")} + label={t("conditions.includeChannels")} + /> + )} + {shows("role") && ( + update({ roleIds: ids })} + placeholder={t("conditions.addRole")} + label={t("conditions.includeRoles")} + /> + )} + {shows("user") && ( + { + const current = conditions.userIds ?? []; + if (!current.includes(id)) update({ userIds: [...current, id] }); + }} + onRemove={(id) => + update({ userIds: (conditions.userIds ?? []).filter((v) => v !== id) }) + } + /> + )}
{/* Exclude filters */} @@ -243,39 +317,45 @@ export function ConditionsEditor({ {t("conditions.exclude")} - update({ excludeChannelIds: ids })} - placeholder={t("conditions.excludeChannel")} - label={t("conditions.channels")} - /> - update({ excludeRoleIds: ids })} - placeholder={t("conditions.excludeRole")} - label={t("conditions.roles")} - /> - { - const current = conditions.excludeUserIds ?? []; - if (!current.includes(id)) - update({ excludeUserIds: [...current, id] }); - }} - onRemove={(id) => - update({ - excludeUserIds: (conditions.excludeUserIds ?? []).filter( - (v) => v !== id, - ), - }) - } - chipColor="destructive" - /> + {shows("channel") && ( + update({ excludeChannelIds: ids })} + placeholder={t("conditions.excludeChannel")} + label={t("conditions.excludeChannels")} + /> + )} + {shows("role") && ( + update({ excludeRoleIds: ids })} + placeholder={t("conditions.excludeRole")} + label={t("conditions.excludeRoles")} + /> + )} + {shows("user") && ( + { + const current = conditions.excludeUserIds ?? []; + if (!current.includes(id)) + update({ excludeUserIds: [...current, id] }); + }} + onRemove={(id) => + update({ + excludeUserIds: (conditions.excludeUserIds ?? []).filter( + (v) => v !== id, + ), + }) + } + chipColor="destructive" + /> + )}
{hasAnyConditions && ( diff --git a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx index 7afd9798..b6185662 100644 --- a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx +++ b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx @@ -287,6 +287,9 @@ function TriggerPanel({ conditions={conditions} onChange={onConditionsChange} guildId={guildId} + // Undefined until a trigger is picked — ConditionsEditor then offers + // everything rather than rendering an empty panel. + supported={eventType ? constants.eventConditionSupport[eventType] : undefined} alwaysExpanded /> diff --git a/apps/dashboard/src/client/shared/lib/schemas.ts b/apps/dashboard/src/client/shared/lib/schemas.ts index 18764a56..f8155442 100644 --- a/apps/dashboard/src/client/shared/lib/schemas.ts +++ b/apps/dashboard/src/client/shared/lib/schemas.ts @@ -74,6 +74,15 @@ export const ConstantsSchema = z.object({ ), eventTypeVariables: z.record(z.string(), z.array(z.string())), templateVariables: z.record(z.string(), z.string()), + /** + * Which filter subjects each event type can evaluate. Trigger filters fail + * closed in the bot, so the editor uses this to avoid offering a filter that + * would silently stop the rule from firing. + */ + eventConditionSupport: z.record( + z.string(), + z.array(z.enum(["channel", "role", "user"])), + ), }); export type Constants = z.infer; diff --git a/apps/dashboard/src/server/features/actions/routes.ts b/apps/dashboard/src/server/features/actions/routes.ts index 365419cb..c6fd8b1c 100644 --- a/apps/dashboard/src/server/features/actions/routes.ts +++ b/apps/dashboard/src/server/features/actions/routes.ts @@ -26,6 +26,7 @@ import { ACTION_TYPE_FIELDS, EVENT_TYPE_VARIABLES, TEMPLATE_VARIABLES, + EVENT_CONDITION_SUPPORT, } from "@fluxcore/systems/actions/constants"; import type { ActionEventType, ActionType, RuleStep } from "@fluxcore/systems/actions/types"; import { channelExistsInGuild } from "../../shared/discordApi.js"; @@ -227,6 +228,7 @@ export function registerActionRoutes(app: FastifyInstance): void { actionTypeFields: { type: "object", additionalProperties: true }, eventTypeVariables: { type: "object", additionalProperties: true }, templateVariables: { type: "object", additionalProperties: true }, + eventConditionSupport: { type: "object", additionalProperties: true }, }, }, }, @@ -240,6 +242,9 @@ export function registerActionRoutes(app: FastifyInstance): void { actionTypeFields: ACTION_TYPE_FIELDS, eventTypeVariables: EVENT_TYPE_VARIABLES, templateVariables: TEMPLATE_VARIABLES, + // Trigger filters fail closed, so the editor only offers the filters a + // given trigger can actually satisfy. + eventConditionSupport: EVENT_CONDITION_SUPPORT, }); }, ); diff --git a/apps/dashboard/tests/client/features/automation/ConditionsEditor.test.tsx b/apps/dashboard/tests/client/features/automation/ConditionsEditor.test.tsx new file mode 100644 index 00000000..8fec9c9c --- /dev/null +++ b/apps/dashboard/tests/client/features/automation/ConditionsEditor.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConditionsEditor } from "../../../../src/client/features/automation/components/ConditionsEditor"; + +function translate(key: string, opts?: Record): string { + return opts ? `${key}:${JSON.stringify(opts)}` : key; +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: translate, i18n: { dir: () => "ltr" } }), +})); + +// DiscordMultiSelect fetches guild channels/roles; the group's presence is what +// these tests assert, not its contents. +vi.mock("../../../../src/client/shared/ui/discord-multi-select", () => ({ + DiscordMultiSelect: ({ label, type }: { label: string; type: string }) => ( +
{label}
+ ), +})); + +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +const noop = () => {}; + +describe("ConditionsEditor — only offers filters the trigger can satisfy", () => { + // Filters fail closed (executor.ts matchesConditions), so offering a filter + // the event context can never answer does not merely do nothing — it + // silently stops the rule from ever firing. + it("hides channel filters for a trigger that carries no channel", () => { + render( + , + ); + + expect(screen.queryByTestId(/multiselect-any-/)).not.toBeInTheDocument(); + // Role filters survive, in both the include and the exclude group. + expect(screen.getAllByTestId(/multiselect-role-/)).toHaveLength(2); + }); + + it("hides role filters for a trigger with no member in context", () => { + render( + , + ); + + expect(screen.queryByTestId(/multiselect-role-/)).not.toBeInTheDocument(); + }); + + it("hides user filters for a trigger with no acting user", () => { + render( + , + ); + + // `conditions.userId` is only the input's placeholder — assert on the + // group's visible label, which is what actually disappears. + expect(screen.queryByText("conditions.users")).not.toBeInTheDocument(); + }); + + it("offers everything when the trigger supports everything", () => { + render( + , + ); + + expect(screen.getAllByTestId(/multiselect-any-/)).toHaveLength(2); + expect(screen.getAllByTestId(/multiselect-role-/)).toHaveLength(2); + }); + + it("offers everything when no trigger is chosen yet", () => { + render( + , + ); + + expect(screen.getAllByTestId(/multiselect-any-/)).toHaveLength(2); + expect(screen.getAllByTestId(/multiselect-role-/)).toHaveLength(2); + }); +}); + +describe("ConditionsEditor — flags filters saved against an incompatible trigger", () => { + // A rule built before the trigger was changed (or before filters failed + // closed) can carry a filter its trigger can never satisfy. Because the + // runtime now refuses to fire it, the editor has to say so. + it("warns when a stored filter cannot be evaluated by the trigger", () => { + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("conditions.unsupportedWarning"); + }); + + it("offers to clear the filters that cannot apply", async () => { + const onChange = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: /conditions\.removeUnsupported/ })); + + // The unusable channel filter goes; the usable user filter stays. + expect(onChange).toHaveBeenCalledWith({ userIds: ["u1"] }); + }); + + it("stays quiet when every stored filter is supported", () => { + render( + , + ); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/tests/server/features/actions/actions.test.ts b/apps/dashboard/tests/server/features/actions/actions.test.ts index 76eabf75..d48b1fd6 100644 --- a/apps/dashboard/tests/server/features/actions/actions.test.ts +++ b/apps/dashboard/tests/server/features/actions/actions.test.ts @@ -122,6 +122,25 @@ describe("action routes", () => { expect(body.actionTypes).toBeDefined(); expect(body.maxActionsPerRule).toBe(5); }); + + // Filters fail closed, so the editor must know which of them a given + // trigger can actually satisfy — otherwise it lets users build a rule that + // silently never fires. + it("exposes which filter subjects each event type supports", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/actions/constants", + cookies: { session: app.signCookie("valid") }, + }); + const body = res.json(); + + expect(body.eventConditionSupport).toBeDefined(); + expect(body.eventConditionSupport.memberJoin).toEqual( + expect.arrayContaining(["user", "role"]), + ); + expect(body.eventConditionSupport.memberJoin).not.toContain("channel"); + expect(body.eventConditionSupport.memberBanned).not.toContain("role"); + }); }); describe("GET /api/guilds/:guildId/actions/rules", () => { diff --git a/packages/i18n/src/locales/en/rules.json b/packages/i18n/src/locales/en/rules.json index 52934640..0b3c0854 100644 --- a/packages/i18n/src/locales/en/rules.json +++ b/packages/i18n/src/locales/en/rules.json @@ -150,7 +150,15 @@ "removeItem": "Remove {{label}}", "collapse": "Collapse conditions", "userIdInvalid": "Enter a valid Discord user ID (17–20 digits)", - "addUserId": "Add user ID" + "addUserId": "Add user ID", + "includeChannels": "Only in these channels", + "includeRoles": "Only members with these roles", + "includeUsers": "Only these members", + "excludeChannels": "Never in these channels", + "excludeRoles": "Never members with these roles", + "excludeUsers": "Never these members", + "unsupportedWarning": "This trigger cannot evaluate these filters ({{filters}}), so the rule will never fire while they are set.", + "removeUnsupported": "Remove filters that cannot apply" }, "editor": { "backToRules": "Rules", From 63693138957e5378a96428c32321b2d99c9a0e72 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 22:13:05 +0300 Subject: [PATCH 06/19] i18n(rules): translate the trigger-filter condition keys in all 47 locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the 8 keys added by the event-aware filter work (the distinct include/exclude group labels, the unsupported-filter warning and its clear action) plus 3 that an earlier a11y pass had left English-only (collapse, userIdInvalid, addUserId). 47 locales x 11 keys. Every file is a pure insertion (+12/-1: the 11 keys plus the trailing comma on the previous last key) — no reformatting, verified against the semi-compact and \u-escaped files that a JSON round-trip would otherwise rewrite wholesale. Co-Authored-By: Claude Opus 5 (1M context) --- packages/i18n/src/locales/af/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ar/rules.json | 13 ++++++++++++- packages/i18n/src/locales/bg/rules.json | 13 ++++++++++++- packages/i18n/src/locales/bn/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ca/rules.json | 13 ++++++++++++- packages/i18n/src/locales/cs/rules.json | 13 ++++++++++++- packages/i18n/src/locales/da/rules.json | 13 ++++++++++++- packages/i18n/src/locales/de/rules.json | 13 ++++++++++++- packages/i18n/src/locales/el/rules.json | 13 ++++++++++++- packages/i18n/src/locales/es/rules.json | 13 ++++++++++++- packages/i18n/src/locales/et/rules.json | 13 ++++++++++++- packages/i18n/src/locales/eu/rules.json | 13 ++++++++++++- packages/i18n/src/locales/fa/rules.json | 13 ++++++++++++- packages/i18n/src/locales/fi/rules.json | 13 ++++++++++++- packages/i18n/src/locales/fil/rules.json | 13 ++++++++++++- packages/i18n/src/locales/fr/rules.json | 13 ++++++++++++- packages/i18n/src/locales/gl/rules.json | 13 ++++++++++++- packages/i18n/src/locales/he/rules.json | 13 ++++++++++++- packages/i18n/src/locales/hi/rules.json | 13 ++++++++++++- packages/i18n/src/locales/hr/rules.json | 13 ++++++++++++- packages/i18n/src/locales/hu/rules.json | 13 ++++++++++++- packages/i18n/src/locales/id/rules.json | 13 ++++++++++++- packages/i18n/src/locales/it/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ja/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ko/rules.json | 13 ++++++++++++- packages/i18n/src/locales/lt/rules.json | 13 ++++++++++++- packages/i18n/src/locales/lv/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ms/rules.json | 13 ++++++++++++- packages/i18n/src/locales/nl/rules.json | 13 ++++++++++++- packages/i18n/src/locales/no/rules.json | 13 ++++++++++++- packages/i18n/src/locales/pl/rules.json | 13 ++++++++++++- packages/i18n/src/locales/pt/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ro/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ru/rules.json | 13 ++++++++++++- packages/i18n/src/locales/sk/rules.json | 13 ++++++++++++- packages/i18n/src/locales/sl/rules.json | 13 ++++++++++++- packages/i18n/src/locales/sr/rules.json | 13 ++++++++++++- packages/i18n/src/locales/sv/rules.json | 13 ++++++++++++- packages/i18n/src/locales/sw/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ta/rules.json | 13 ++++++++++++- packages/i18n/src/locales/th/rules.json | 13 ++++++++++++- packages/i18n/src/locales/tr/rules.json | 13 ++++++++++++- packages/i18n/src/locales/uk/rules.json | 13 ++++++++++++- packages/i18n/src/locales/ur/rules.json | 13 ++++++++++++- packages/i18n/src/locales/vi/rules.json | 13 ++++++++++++- packages/i18n/src/locales/zh-CN/rules.json | 13 ++++++++++++- packages/i18n/src/locales/zh-TW/rules.json | 13 ++++++++++++- 47 files changed, 564 insertions(+), 47 deletions(-) diff --git a/packages/i18n/src/locales/af/rules.json b/packages/i18n/src/locales/af/rules.json index 2afd1b84..36009da2 100644 --- a/packages/i18n/src/locales/af/rules.json +++ b/packages/i18n/src/locales/af/rules.json @@ -146,7 +146,18 @@ "include": "Sluit In (witlys)", "exclude": "Sluit Uit (swartelys)", "clearAll": "Maak alle voorwaardes skoon", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Vou voorwaardes in", + "userIdInvalid": "Voer 'n geldige Discord-gebruiker-ID in (17–20 syfers)", + "addUserId": "Voeg gebruiker-ID by", + "includeChannels": "Slegs in hierdie kanale", + "includeRoles": "Slegs lede met hierdie rolle", + "includeUsers": "Slegs hierdie lede", + "excludeChannels": "Nooit in hierdie kanale nie", + "excludeRoles": "Nooit lede met hierdie rolle nie", + "excludeUsers": "Nooit hierdie lede nie", + "unsupportedWarning": "Hierdie sneller kan nie hierdie filters ({{filters}}) evalueer nie, dus sal die reël nooit afgaan solank hulle gestel is nie.", + "removeUnsupported": "Verwyder filters wat nie kan geld nie" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ar/rules.json b/packages/i18n/src/locales/ar/rules.json index c5efa0b8..a31fc5a8 100644 --- a/packages/i18n/src/locales/ar/rules.json +++ b/packages/i18n/src/locales/ar/rules.json @@ -146,7 +146,18 @@ "include": "تضمين (قائمة بيضاء)", "exclude": "استثناء (قائمة سوداء)", "clearAll": "مسح جميع الشروط", - "removeItem": "إزالة {{label}}" + "removeItem": "إزالة {{label}}", + "collapse": "طيّ الشروط", + "userIdInvalid": "أدخل معرّف مستخدم Discord صالحًا (17–20 رقمًا)", + "addUserId": "إضافة معرّف مستخدم", + "includeChannels": "في هذه القنوات فقط", + "includeRoles": "الأعضاء الحاصلون على هذه الأدوار فقط", + "includeUsers": "هؤلاء الأعضاء فقط", + "excludeChannels": "لا في هذه القنوات أبدًا", + "excludeRoles": "لا الأعضاء الحاصلون على هذه الأدوار أبدًا", + "excludeUsers": "لا هؤلاء الأعضاء أبدًا", + "unsupportedWarning": "لا يستطيع هذا المشغّل تقييم هذه المرشّحات ({{filters}})، لذا لن تُنفَّذ القاعدة ما دامت مضبوطة.", + "removeUnsupported": "إزالة المرشّحات التي لا يمكن تطبيقها" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/bg/rules.json b/packages/i18n/src/locales/bg/rules.json index 92d2365d..82f07c7e 100644 --- a/packages/i18n/src/locales/bg/rules.json +++ b/packages/i18n/src/locales/bg/rules.json @@ -146,7 +146,18 @@ "include": "Включи (бял списък)", "exclude": "Изключи (черен списък)", "clearAll": "Изчисти всички условия", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Свиване на условията", + "userIdInvalid": "Въведете валиден Discord потребителски идентификатор (17–20 цифри)", + "addUserId": "Добавяне на потребителски идентификатор", + "includeChannels": "Само в тези канали", + "includeRoles": "Само членове с тези роли", + "includeUsers": "Само тези членове", + "excludeChannels": "Никога в тези канали", + "excludeRoles": "Никога членове с тези роли", + "excludeUsers": "Никога тези членове", + "unsupportedWarning": "Този тригер не може да оцени тези филтри ({{filters}}), затова правилото никога няма да се задейства, докато са зададени.", + "removeUnsupported": "Премахване на филтрите, които не могат да се приложат" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/bn/rules.json b/packages/i18n/src/locales/bn/rules.json index a066aef5..bd783306 100644 --- a/packages/i18n/src/locales/bn/rules.json +++ b/packages/i18n/src/locales/bn/rules.json @@ -146,7 +146,18 @@ "include": "অন্তর্ভুক্ত (হোয়াইটলিস্ট)", "exclude": "বর্জন (ব্ল্যাকলিস্ট)", "clearAll": "সমস্ত শর্ত সাফ করুন", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "শর্তগুলি সংকুচিত করুন", + "userIdInvalid": "একটি বৈধ Discord ব্যবহারকারী আইডি লিখুন (১৭–২০ সংখ্যা)", + "addUserId": "ব্যবহারকারী আইডি যোগ করুন", + "includeChannels": "শুধু এই চ্যানেলগুলিতে", + "includeRoles": "শুধু এই ভূমিকাগুলির সদস্যরা", + "includeUsers": "শুধু এই সদস্যরা", + "excludeChannels": "কখনও এই চ্যানেলগুলিতে নয়", + "excludeRoles": "কখনও এই ভূমিকাগুলির সদস্যরা নয়", + "excludeUsers": "কখনও এই সদস্যরা নয়", + "unsupportedWarning": "এই ট্রিগারটি এই ফিল্টারগুলি ({{filters}}) মূল্যায়ন করতে পারে না, তাই সেগুলি সেট থাকা অবস্থায় নিয়মটি কখনও চলবে না।", + "removeUnsupported": "যে ফিল্টারগুলি প্রযোজ্য নয় সেগুলি সরান" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ca/rules.json b/packages/i18n/src/locales/ca/rules.json index 9cebc1c8..31104990 100644 --- a/packages/i18n/src/locales/ca/rules.json +++ b/packages/i18n/src/locales/ca/rules.json @@ -146,7 +146,18 @@ "include": "Incloure (llista blanca)", "exclude": "Excloure (llista negra)", "clearAll": "Netejar totes les condicions", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Replega les condicions", + "userIdInvalid": "Introdueix un ID d'usuari de Discord vàlid (17–20 dígits)", + "addUserId": "Afegeix un ID d'usuari", + "includeChannels": "Només en aquests canals", + "includeRoles": "Només membres amb aquests rols", + "includeUsers": "Només aquests membres", + "excludeChannels": "Mai en aquests canals", + "excludeRoles": "Mai membres amb aquests rols", + "excludeUsers": "Mai aquests membres", + "unsupportedWarning": "Aquest activador no pot avaluar aquests filtres ({{filters}}), de manera que la regla no s'executarà mai mentre estiguin definits.", + "removeUnsupported": "Elimina els filtres que no es poden aplicar" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/cs/rules.json b/packages/i18n/src/locales/cs/rules.json index 18f5bf4e..eb7025dd 100644 --- a/packages/i18n/src/locales/cs/rules.json +++ b/packages/i18n/src/locales/cs/rules.json @@ -146,7 +146,18 @@ "include": "Zahrnout (bílá listina)", "exclude": "Vyloučit (černá listina)", "clearAll": "Vymazat všechny podmínky", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Sbalit podmínky", + "userIdInvalid": "Zadejte platné Discord ID uživatele (17–20 číslic)", + "addUserId": "Přidat ID uživatele", + "includeChannels": "Pouze v těchto kanálech", + "includeRoles": "Pouze členové s těmito rolemi", + "includeUsers": "Pouze tito členové", + "excludeChannels": "Nikdy v těchto kanálech", + "excludeRoles": "Nikdy členové s těmito rolemi", + "excludeUsers": "Nikdy tito členové", + "unsupportedWarning": "Tato spouštěč nedokáže vyhodnotit tyto filtry ({{filters}}), takže se pravidlo nikdy nespustí, dokud jsou nastavené.", + "removeUnsupported": "Odebrat filtry, které nelze použít" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/da/rules.json b/packages/i18n/src/locales/da/rules.json index 0a2b08aa..d893af56 100644 --- a/packages/i18n/src/locales/da/rules.json +++ b/packages/i18n/src/locales/da/rules.json @@ -146,7 +146,18 @@ "include": "Inkluder (hvidliste)", "exclude": "Ekskluder (sortliste)", "clearAll": "Ryd alle betingelser", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Skjul betingelser", + "userIdInvalid": "Indtast et gyldigt Discord-bruger-id (17–20 cifre)", + "addUserId": "Tilføj bruger-id", + "includeChannels": "Kun i disse kanaler", + "includeRoles": "Kun medlemmer med disse roller", + "includeUsers": "Kun disse medlemmer", + "excludeChannels": "Aldrig i disse kanaler", + "excludeRoles": "Aldrig medlemmer med disse roller", + "excludeUsers": "Aldrig disse medlemmer", + "unsupportedWarning": "Denne udløser kan ikke evaluere disse filtre ({{filters}}), så reglen udløses aldrig, så længe de er angivet.", + "removeUnsupported": "Fjern filtre, der ikke kan anvendes" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/de/rules.json b/packages/i18n/src/locales/de/rules.json index 0b8226f7..939fb14c 100644 --- a/packages/i18n/src/locales/de/rules.json +++ b/packages/i18n/src/locales/de/rules.json @@ -146,7 +146,18 @@ "include": "Einschließen (Whitelist)", "exclude": "Ausschließen (Blacklist)", "clearAll": "Alle Bedingungen löschen", - "removeItem": "{{label}} entfernen" + "removeItem": "{{label}} entfernen", + "collapse": "Bedingungen einklappen", + "userIdInvalid": "Gib eine gültige Discord-Benutzer-ID ein (17–20 Ziffern)", + "addUserId": "Benutzer-ID hinzufügen", + "includeChannels": "Nur in diesen Kanälen", + "includeRoles": "Nur Mitglieder mit diesen Rollen", + "includeUsers": "Nur diese Mitglieder", + "excludeChannels": "Nie in diesen Kanälen", + "excludeRoles": "Nie Mitglieder mit diesen Rollen", + "excludeUsers": "Nie diese Mitglieder", + "unsupportedWarning": "Dieser Auslöser kann diese Filter ({{filters}}) nicht auswerten, daher wird die Regel nie ausgelöst, solange sie gesetzt sind.", + "removeUnsupported": "Filter entfernen, die nicht greifen können" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/el/rules.json b/packages/i18n/src/locales/el/rules.json index 263e9584..eafaf251 100644 --- a/packages/i18n/src/locales/el/rules.json +++ b/packages/i18n/src/locales/el/rules.json @@ -146,7 +146,18 @@ "include": "Συμπερίληψη (λευκή λίστα)", "exclude": "Εξαίρεση (μαύρη λίστα)", "clearAll": "Εκκαθάριση όλων των συνθηκών", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Σύμπτυξη συνθηκών", + "userIdInvalid": "Εισαγάγετε έγκυρο αναγνωριστικό χρήστη Discord (17–20 ψηφία)", + "addUserId": "Προσθήκη αναγνωριστικού χρήστη", + "includeChannels": "Μόνο σε αυτά τα κανάλια", + "includeRoles": "Μόνο μέλη με αυτούς τους ρόλους", + "includeUsers": "Μόνο αυτά τα μέλη", + "excludeChannels": "Ποτέ σε αυτά τα κανάλια", + "excludeRoles": "Ποτέ μέλη με αυτούς τους ρόλους", + "excludeUsers": "Ποτέ αυτά τα μέλη", + "unsupportedWarning": "Αυτή η ενεργοποίηση δεν μπορεί να αξιολογήσει αυτά τα φίλτρα ({{filters}}), οπότε ο κανόνας δεν θα εκτελεστεί ποτέ όσο είναι ορισμένα.", + "removeUnsupported": "Κατάργηση φίλτρων που δεν μπορούν να εφαρμοστούν" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/es/rules.json b/packages/i18n/src/locales/es/rules.json index f726da72..12c0d434 100644 --- a/packages/i18n/src/locales/es/rules.json +++ b/packages/i18n/src/locales/es/rules.json @@ -146,7 +146,18 @@ "include": "Incluir (lista blanca)", "exclude": "Excluir (lista negra)", "clearAll": "Limpiar todas las condiciones", - "removeItem": "Quitar {{label}}" + "removeItem": "Quitar {{label}}", + "collapse": "Contraer condiciones", + "userIdInvalid": "Introduce un ID de usuario de Discord válido (17–20 dígitos)", + "addUserId": "Añadir ID de usuario", + "includeChannels": "Solo en estos canales", + "includeRoles": "Solo miembros con estos roles", + "includeUsers": "Solo estos miembros", + "excludeChannels": "Nunca en estos canales", + "excludeRoles": "Nunca miembros con estos roles", + "excludeUsers": "Nunca estos miembros", + "unsupportedWarning": "Este activador no puede evaluar estos filtros ({{filters}}), por lo que la regla nunca se ejecutará mientras estén definidos.", + "removeUnsupported": "Eliminar los filtros que no se pueden aplicar" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/et/rules.json b/packages/i18n/src/locales/et/rules.json index 3f66125b..d8b0d5b2 100644 --- a/packages/i18n/src/locales/et/rules.json +++ b/packages/i18n/src/locales/et/rules.json @@ -146,7 +146,18 @@ "include": "Kaasa (valge nimekiri)", "exclude": "Välista (must nimekiri)", "clearAll": "Tühjenda kõik tingimused", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Ahenda tingimused", + "userIdInvalid": "Sisesta kehtiv Discordi kasutaja ID (17–20 numbrit)", + "addUserId": "Lisa kasutaja ID", + "includeChannels": "Ainult nendes kanalites", + "includeRoles": "Ainult nende rollidega liikmed", + "includeUsers": "Ainult need liikmed", + "excludeChannels": "Mitte kunagi nendes kanalites", + "excludeRoles": "Mitte kunagi nende rollidega liikmed", + "excludeUsers": "Mitte kunagi need liikmed", + "unsupportedWarning": "See päästik ei suuda neid filtreid ({{filters}}) hinnata, seega reegel ei käivitu kunagi, kuni need on määratud.", + "removeUnsupported": "Eemalda filtrid, mida ei saa rakendada" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/eu/rules.json b/packages/i18n/src/locales/eu/rules.json index dfcebd5a..1780536e 100644 --- a/packages/i18n/src/locales/eu/rules.json +++ b/packages/i18n/src/locales/eu/rules.json @@ -146,7 +146,18 @@ "include": "Barne hartu (zerrenda zuria)", "exclude": "Baztertu (zerrenda beltza)", "clearAll": "Baldintza guztiak garbitu", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Tolestu baldintzak", + "userIdInvalid": "Sartu baliozko Discord erabiltzaile ID bat (17–20 digitu)", + "addUserId": "Gehitu erabiltzaile IDa", + "includeChannels": "Kanal hauetan bakarrik", + "includeRoles": "Rol hauek dituzten kideak bakarrik", + "includeUsers": "Kide hauek bakarrik", + "excludeChannels": "Inoiz ez kanal hauetan", + "excludeRoles": "Inoiz ez rol hauek dituzten kideak", + "excludeUsers": "Inoiz ez kide hauek", + "unsupportedWarning": "Abiarazle honek ezin ditu iragazki hauek ({{filters}}) ebaluatu, beraz araua ez da inoiz exekutatuko ezarrita dauden bitartean.", + "removeUnsupported": "Kendu aplikatu ezin diren iragazkiak" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/fa/rules.json b/packages/i18n/src/locales/fa/rules.json index 0f782e7d..f5dac1b8 100644 --- a/packages/i18n/src/locales/fa/rules.json +++ b/packages/i18n/src/locales/fa/rules.json @@ -146,7 +146,18 @@ "include": "شامل‌کردن (فهرست سفید)", "exclude": "حذف (فهرست سیاه)", "clearAll": "پاک کردن همه شرایط", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "جمع کردن شرط‌ها", + "userIdInvalid": "یک شناسه کاربری معتبر Discord وارد کنید (۱۷ تا ۲۰ رقم)", + "addUserId": "افزودن شناسه کاربر", + "includeChannels": "فقط در این کانال‌ها", + "includeRoles": "فقط اعضای دارای این نقش‌ها", + "includeUsers": "فقط این اعضا", + "excludeChannels": "هرگز در این کانال‌ها", + "excludeRoles": "هرگز اعضای دارای این نقش‌ها", + "excludeUsers": "هرگز این اعضا", + "unsupportedWarning": "این راه‌انداز نمی‌تواند این فیلترها ({{filters}}) را ارزیابی کند، بنابراین تا زمانی که تنظیم شده باشند قانون هرگز اجرا نمی‌شود.", + "removeUnsupported": "حذف فیلترهایی که قابل اعمال نیستند" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/fi/rules.json b/packages/i18n/src/locales/fi/rules.json index c38be2c6..b95ee3a0 100644 --- a/packages/i18n/src/locales/fi/rules.json +++ b/packages/i18n/src/locales/fi/rules.json @@ -146,7 +146,18 @@ "include": "Sisällytä (valkolista)", "exclude": "Sulje pois (mustat lista)", "clearAll": "Tyhjennä kaikki ehdot", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Tiivistä ehdot", + "userIdInvalid": "Anna kelvollinen Discord-käyttäjätunnus (17–20 numeroa)", + "addUserId": "Lisää käyttäjätunnus", + "includeChannels": "Vain näissä kanavissa", + "includeRoles": "Vain jäsenet, joilla on nämä roolit", + "includeUsers": "Vain nämä jäsenet", + "excludeChannels": "Ei koskaan näissä kanavissa", + "excludeRoles": "Ei koskaan jäsenet, joilla on nämä roolit", + "excludeUsers": "Ei koskaan nämä jäsenet", + "unsupportedWarning": "Tämä liipaisin ei voi arvioida näitä suodattimia ({{filters}}), joten sääntö ei koskaan laukea niin kauan kuin ne on asetettu.", + "removeUnsupported": "Poista suodattimet, joita ei voi soveltaa" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/fil/rules.json b/packages/i18n/src/locales/fil/rules.json index 9ab8872f..84469d85 100644 --- a/packages/i18n/src/locales/fil/rules.json +++ b/packages/i18n/src/locales/fil/rules.json @@ -146,7 +146,18 @@ "include": "Isama (whitelist)", "exclude": "I-exclude (blacklist)", "clearAll": "I-clear ang lahat ng kondisyon", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "I-collapse ang mga kundisyon", + "userIdInvalid": "Maglagay ng wastong Discord user ID (17–20 digit)", + "addUserId": "Magdagdag ng user ID", + "includeChannels": "Sa mga channel na ito lamang", + "includeRoles": "Mga miyembro lamang na may ganitong role", + "includeUsers": "Ang mga miyembrong ito lamang", + "excludeChannels": "Hindi kailanman sa mga channel na ito", + "excludeRoles": "Hindi kailanman ang mga miyembrong may ganitong role", + "excludeUsers": "Hindi kailanman ang mga miyembrong ito", + "unsupportedWarning": "Hindi masusuri ng trigger na ito ang mga filter na ito ({{filters}}), kaya hindi kailanman tatakbo ang panuntunan habang nakatakda ang mga ito.", + "removeUnsupported": "Alisin ang mga filter na hindi maaaring ilapat" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/fr/rules.json b/packages/i18n/src/locales/fr/rules.json index d32d7e0f..b3956364 100644 --- a/packages/i18n/src/locales/fr/rules.json +++ b/packages/i18n/src/locales/fr/rules.json @@ -146,7 +146,18 @@ "include": "Inclure (liste blanche)", "exclude": "Exclure (liste noire)", "clearAll": "Effacer toutes les conditions", - "removeItem": "Retirer {{label}}" + "removeItem": "Retirer {{label}}", + "collapse": "Réduire les conditions", + "userIdInvalid": "Saisissez un identifiant utilisateur Discord valide (17 à 20 chiffres)", + "addUserId": "Ajouter un identifiant utilisateur", + "includeChannels": "Uniquement dans ces salons", + "includeRoles": "Uniquement les membres ayant ces rôles", + "includeUsers": "Uniquement ces membres", + "excludeChannels": "Jamais dans ces salons", + "excludeRoles": "Jamais les membres ayant ces rôles", + "excludeUsers": "Jamais ces membres", + "unsupportedWarning": "Ce déclencheur ne peut pas évaluer ces filtres ({{filters}}) ; la règle ne se déclenchera donc jamais tant qu'ils sont définis.", + "removeUnsupported": "Supprimer les filtres inapplicables" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/gl/rules.json b/packages/i18n/src/locales/gl/rules.json index 82ec0a3e..6d4d846f 100644 --- a/packages/i18n/src/locales/gl/rules.json +++ b/packages/i18n/src/locales/gl/rules.json @@ -146,7 +146,18 @@ "include": "Incluír (lista branca)", "exclude": "Excluír (lista negra)", "clearAll": "Limpar todas as condicións", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Contraer condicións", + "userIdInvalid": "Introduce un ID de usuario de Discord válido (17–20 díxitos)", + "addUserId": "Engadir ID de usuario", + "includeChannels": "Só nestas canles", + "includeRoles": "Só membros con estes roles", + "includeUsers": "Só estes membros", + "excludeChannels": "Nunca nestas canles", + "excludeRoles": "Nunca membros con estes roles", + "excludeUsers": "Nunca estes membros", + "unsupportedWarning": "Este activador non pode avaliar estes filtros ({{filters}}), polo que a regra nunca se executará mentres estean definidos.", + "removeUnsupported": "Eliminar os filtros que non se poden aplicar" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/he/rules.json b/packages/i18n/src/locales/he/rules.json index 15451ce0..4bb5964c 100644 --- a/packages/i18n/src/locales/he/rules.json +++ b/packages/i18n/src/locales/he/rules.json @@ -146,7 +146,18 @@ "include": "כלול (רשימה לבנה)", "exclude": "אל תכלול (רשימה שחורה)", "clearAll": "נקה את כל התנאים", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "כווץ תנאים", + "userIdInvalid": "הזן מזהה משתמש Discord תקין (17–20 ספרות)", + "addUserId": "הוסף מזהה משתמש", + "includeChannels": "רק בערוצים אלה", + "includeRoles": "רק חברים עם תפקידים אלה", + "includeUsers": "רק חברים אלה", + "excludeChannels": "אף פעם בערוצים אלה", + "excludeRoles": "אף פעם חברים עם תפקידים אלה", + "excludeUsers": "אף פעם חברים אלה", + "unsupportedWarning": "טריגר זה אינו יכול להעריך את המסננים האלה ({{filters}}), ולכן הכלל לעולם לא יופעל כל עוד הם מוגדרים.", + "removeUnsupported": "הסר מסננים שאינם ישימים" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/hi/rules.json b/packages/i18n/src/locales/hi/rules.json index 76c15953..ad9f8dc3 100644 --- a/packages/i18n/src/locales/hi/rules.json +++ b/packages/i18n/src/locales/hi/rules.json @@ -146,7 +146,18 @@ "include": "शामिल करें (व्हाइटलिस्ट)", "exclude": "बाहर करें (ब्लैकलिस्ट)", "clearAll": "सभी शर्तें साफ़ करें", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "शर्तें संक्षिप्त करें", + "userIdInvalid": "मान्य Discord उपयोगकर्ता आईडी दर्ज करें (17–20 अंक)", + "addUserId": "उपयोगकर्ता आईडी जोड़ें", + "includeChannels": "केवल इन चैनलों में", + "includeRoles": "केवल इन भूमिकाओं वाले सदस्य", + "includeUsers": "केवल ये सदस्य", + "excludeChannels": "कभी भी इन चैनलों में नहीं", + "excludeRoles": "कभी भी इन भूमिकाओं वाले सदस्य नहीं", + "excludeUsers": "कभी भी ये सदस्य नहीं", + "unsupportedWarning": "यह ट्रिगर इन फ़िल्टर ({{filters}}) का मूल्यांकन नहीं कर सकता, इसलिए जब तक वे सेट हैं नियम कभी नहीं चलेगा।", + "removeUnsupported": "जो फ़िल्टर लागू नहीं हो सकते उन्हें हटाएँ" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/hr/rules.json b/packages/i18n/src/locales/hr/rules.json index c88d2993..9d5185a5 100644 --- a/packages/i18n/src/locales/hr/rules.json +++ b/packages/i18n/src/locales/hr/rules.json @@ -146,7 +146,18 @@ "include": "Uključi (bijela lista)", "exclude": "Isključi (crna lista)", "clearAll": "Obriši sve uvjete", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Sažmi uvjete", + "userIdInvalid": "Unesite valjani Discord korisnički ID (17–20 znamenki)", + "addUserId": "Dodaj korisnički ID", + "includeChannels": "Samo u ovim kanalima", + "includeRoles": "Samo članovi s ovim ulogama", + "includeUsers": "Samo ovi članovi", + "excludeChannels": "Nikada u ovim kanalima", + "excludeRoles": "Nikada članovi s ovim ulogama", + "excludeUsers": "Nikada ovi članovi", + "unsupportedWarning": "Ovaj okidač ne može procijeniti ove filtre ({{filters}}), pa se pravilo nikada neće pokrenuti dok su postavljeni.", + "removeUnsupported": "Ukloni filtre koji se ne mogu primijeniti" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/hu/rules.json b/packages/i18n/src/locales/hu/rules.json index f5fb5185..80874a69 100644 --- a/packages/i18n/src/locales/hu/rules.json +++ b/packages/i18n/src/locales/hu/rules.json @@ -146,7 +146,18 @@ "include": "Belefoglalás (fehérlista)", "exclude": "Kizárás (feketelista)", "clearAll": "Összes feltétel törlése", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Feltételek összecsukása", + "userIdInvalid": "Adjon meg érvényes Discord felhasználói azonosítót (17–20 számjegy)", + "addUserId": "Felhasználói azonosító hozzáadása", + "includeChannels": "Csak ezekben a csatornákban", + "includeRoles": "Csak az ilyen szerepkörű tagok", + "includeUsers": "Csak ezek a tagok", + "excludeChannels": "Soha ezekben a csatornákban", + "excludeRoles": "Soha az ilyen szerepkörű tagok", + "excludeUsers": "Soha ezek a tagok", + "unsupportedWarning": "Ez az eseményindító nem tudja kiértékelni ezeket a szűrőket ({{filters}}), ezért a szabály soha nem fut le, amíg be vannak állítva.", + "removeUnsupported": "Nem alkalmazható szűrők eltávolítása" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/id/rules.json b/packages/i18n/src/locales/id/rules.json index 5db08b8d..d081e9f6 100644 --- a/packages/i18n/src/locales/id/rules.json +++ b/packages/i18n/src/locales/id/rules.json @@ -146,7 +146,18 @@ "include": "Sertakan (daftar putih)", "exclude": "Kecualikan (daftar hitam)", "clearAll": "Hapus semua kondisi", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Ciutkan kondisi", + "userIdInvalid": "Masukkan ID pengguna Discord yang valid (17–20 digit)", + "addUserId": "Tambahkan ID pengguna", + "includeChannels": "Hanya di kanal ini", + "includeRoles": "Hanya anggota dengan peran ini", + "includeUsers": "Hanya anggota ini", + "excludeChannels": "Tidak pernah di kanal ini", + "excludeRoles": "Tidak pernah anggota dengan peran ini", + "excludeUsers": "Tidak pernah anggota ini", + "unsupportedWarning": "Pemicu ini tidak dapat mengevaluasi filter ini ({{filters}}), sehingga aturan tidak akan pernah berjalan selama filter tersebut disetel.", + "removeUnsupported": "Hapus filter yang tidak dapat diterapkan" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/it/rules.json b/packages/i18n/src/locales/it/rules.json index 6ee65a7e..c9380754 100644 --- a/packages/i18n/src/locales/it/rules.json +++ b/packages/i18n/src/locales/it/rules.json @@ -146,7 +146,18 @@ "include": "Includi (whitelist)", "exclude": "Escludi (blacklist)", "clearAll": "Cancella tutte le condizioni", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Comprimi condizioni", + "userIdInvalid": "Inserisci un ID utente Discord valido (17–20 cifre)", + "addUserId": "Aggiungi ID utente", + "includeChannels": "Solo in questi canali", + "includeRoles": "Solo membri con questi ruoli", + "includeUsers": "Solo questi membri", + "excludeChannels": "Mai in questi canali", + "excludeRoles": "Mai membri con questi ruoli", + "excludeUsers": "Mai questi membri", + "unsupportedWarning": "Questo trigger non può valutare questi filtri ({{filters}}), quindi la regola non verrà mai eseguita finché sono impostati.", + "removeUnsupported": "Rimuovi i filtri non applicabili" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ja/rules.json b/packages/i18n/src/locales/ja/rules.json index 57d73fde..f27d45cd 100644 --- a/packages/i18n/src/locales/ja/rules.json +++ b/packages/i18n/src/locales/ja/rules.json @@ -146,7 +146,18 @@ "include": "含める(ホワイトリスト)", "exclude": "除外する(ブラックリスト)", "clearAll": "すべての条件をクリア", - "removeItem": "{{label}}を削除" + "removeItem": "{{label}}を削除", + "collapse": "条件を折りたたむ", + "userIdInvalid": "有効な Discord ユーザー ID を入力してください(17〜20 桁)", + "addUserId": "ユーザー ID を追加", + "includeChannels": "これらのチャンネルのみ", + "includeRoles": "これらのロールを持つメンバーのみ", + "includeUsers": "これらのメンバーのみ", + "excludeChannels": "これらのチャンネルでは実行しない", + "excludeRoles": "これらのロールを持つメンバーでは実行しない", + "excludeUsers": "これらのメンバーでは実行しない", + "unsupportedWarning": "このトリガーはこれらのフィルター({{filters}})を評価できないため、設定されている間ルールは実行されません。", + "removeUnsupported": "適用できないフィルターを削除" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ko/rules.json b/packages/i18n/src/locales/ko/rules.json index eee7c0f7..6ae4853a 100644 --- a/packages/i18n/src/locales/ko/rules.json +++ b/packages/i18n/src/locales/ko/rules.json @@ -146,7 +146,18 @@ "include": "포함 (화이트리스트)", "exclude": "제외 (블랙리스트)", "clearAll": "모든 조건 초기화", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "조건 접기", + "userIdInvalid": "유효한 Discord 사용자 ID를 입력하세요 (17~20자리)", + "addUserId": "사용자 ID 추가", + "includeChannels": "이 채널에서만", + "includeRoles": "이 역할을 가진 멤버만", + "includeUsers": "이 멤버만", + "excludeChannels": "이 채널에서는 제외", + "excludeRoles": "이 역할을 가진 멤버 제외", + "excludeUsers": "이 멤버 제외", + "unsupportedWarning": "이 트리거는 이 필터({{filters}})를 평가할 수 없으므로 필터가 설정되어 있는 동안 규칙이 실행되지 않습니다.", + "removeUnsupported": "적용할 수 없는 필터 제거" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/lt/rules.json b/packages/i18n/src/locales/lt/rules.json index 340929cf..3ce96c4a 100644 --- a/packages/i18n/src/locales/lt/rules.json +++ b/packages/i18n/src/locales/lt/rules.json @@ -146,7 +146,18 @@ "include": "Įtraukti (baltasis sąrašas)", "exclude": "Neįtraukti (juodasis sąrašas)", "clearAll": "Išvalyti visas sąlygas", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Suskleisti sąlygas", + "userIdInvalid": "Įveskite galiojantį Discord naudotojo ID (17–20 skaitmenų)", + "addUserId": "Pridėti naudotojo ID", + "includeChannels": "Tik šiuose kanaluose", + "includeRoles": "Tik nariai su šiais vaidmenimis", + "includeUsers": "Tik šie nariai", + "excludeChannels": "Niekada šiuose kanaluose", + "excludeRoles": "Niekada nariai su šiais vaidmenimis", + "excludeUsers": "Niekada šie nariai", + "unsupportedWarning": "Šis paleidiklis negali įvertinti šių filtrų ({{filters}}), todėl taisyklė niekada nebus vykdoma, kol jie nustatyti.", + "removeUnsupported": "Pašalinti filtrus, kurių negalima pritaikyti" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/lv/rules.json b/packages/i18n/src/locales/lv/rules.json index ce2c2aca..d206ce6a 100644 --- a/packages/i18n/src/locales/lv/rules.json +++ b/packages/i18n/src/locales/lv/rules.json @@ -146,7 +146,18 @@ "include": "Iekļaut (baltais saraksts)", "exclude": "Izslēgt (melnais saraksts)", "clearAll": "Notīrīt visus nosacījumus", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Sakļaut nosacījumus", + "userIdInvalid": "Ievadiet derīgu Discord lietotāja ID (17–20 cipari)", + "addUserId": "Pievienot lietotāja ID", + "includeChannels": "Tikai šajos kanālos", + "includeRoles": "Tikai dalībnieki ar šīm lomām", + "includeUsers": "Tikai šie dalībnieki", + "excludeChannels": "Nekad šajos kanālos", + "excludeRoles": "Nekad dalībnieki ar šīm lomām", + "excludeUsers": "Nekad šie dalībnieki", + "unsupportedWarning": "Šis aktivizētājs nevar novērtēt šos filtrus ({{filters}}), tāpēc noteikums nekad netiks izpildīts, kamēr tie ir iestatīti.", + "removeUnsupported": "Noņemt filtrus, kurus nevar piemērot" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ms/rules.json b/packages/i18n/src/locales/ms/rules.json index e44edb3b..a043864b 100644 --- a/packages/i18n/src/locales/ms/rules.json +++ b/packages/i18n/src/locales/ms/rules.json @@ -146,7 +146,18 @@ "include": "Sertakan (senarai putih)", "exclude": "Kecualikan (senarai hitam)", "clearAll": "Kosongkan semua syarat", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Kuncupkan syarat", + "userIdInvalid": "Masukkan ID pengguna Discord yang sah (17–20 digit)", + "addUserId": "Tambah ID pengguna", + "includeChannels": "Hanya dalam saluran ini", + "includeRoles": "Hanya ahli dengan peranan ini", + "includeUsers": "Hanya ahli ini", + "excludeChannels": "Tidak pernah dalam saluran ini", + "excludeRoles": "Tidak pernah ahli dengan peranan ini", + "excludeUsers": "Tidak pernah ahli ini", + "unsupportedWarning": "Pencetus ini tidak dapat menilai penapis ini ({{filters}}), jadi peraturan tidak akan berjalan selagi ia ditetapkan.", + "removeUnsupported": "Buang penapis yang tidak boleh digunakan" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/nl/rules.json b/packages/i18n/src/locales/nl/rules.json index 1b9ddc3e..22c9be82 100644 --- a/packages/i18n/src/locales/nl/rules.json +++ b/packages/i18n/src/locales/nl/rules.json @@ -146,7 +146,18 @@ "include": "Insluiten (whitelist)", "exclude": "Uitsluiten (blacklist)", "clearAll": "Alle voorwaarden wissen", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Voorwaarden inklappen", + "userIdInvalid": "Voer een geldige Discord-gebruikers-ID in (17–20 cijfers)", + "addUserId": "Gebruikers-ID toevoegen", + "includeChannels": "Alleen in deze kanalen", + "includeRoles": "Alleen leden met deze rollen", + "includeUsers": "Alleen deze leden", + "excludeChannels": "Nooit in deze kanalen", + "excludeRoles": "Nooit leden met deze rollen", + "excludeUsers": "Nooit deze leden", + "unsupportedWarning": "Deze trigger kan deze filters ({{filters}}) niet evalueren, dus de regel wordt nooit uitgevoerd zolang ze zijn ingesteld.", + "removeUnsupported": "Filters verwijderen die niet van toepassing kunnen zijn" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/no/rules.json b/packages/i18n/src/locales/no/rules.json index 2683c378..6289e5f2 100644 --- a/packages/i18n/src/locales/no/rules.json +++ b/packages/i18n/src/locales/no/rules.json @@ -146,7 +146,18 @@ "include": "Inkluder (hvitliste)", "exclude": "Ekskluder (svarteliste)", "clearAll": "Tøm alle betingelser", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Skjul betingelser", + "userIdInvalid": "Skriv inn en gyldig Discord-bruker-ID (17–20 sifre)", + "addUserId": "Legg til bruker-ID", + "includeChannels": "Bare i disse kanalene", + "includeRoles": "Bare medlemmer med disse rollene", + "includeUsers": "Bare disse medlemmene", + "excludeChannels": "Aldri i disse kanalene", + "excludeRoles": "Aldri medlemmer med disse rollene", + "excludeUsers": "Aldri disse medlemmene", + "unsupportedWarning": "Denne utløseren kan ikke evaluere disse filtrene ({{filters}}), så regelen vil aldri kjøre så lenge de er angitt.", + "removeUnsupported": "Fjern filtre som ikke kan brukes" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/pl/rules.json b/packages/i18n/src/locales/pl/rules.json index 3d5e3f0b..91e76bb0 100644 --- a/packages/i18n/src/locales/pl/rules.json +++ b/packages/i18n/src/locales/pl/rules.json @@ -146,7 +146,18 @@ "include": "Dołącz (biała lista)", "exclude": "Wyklucz (czarna lista)", "clearAll": "Wyczyść wszystkie warunki", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Zwiń warunki", + "userIdInvalid": "Wprowadź prawidłowy identyfikator użytkownika Discord (17–20 cyfr)", + "addUserId": "Dodaj identyfikator użytkownika", + "includeChannels": "Tylko na tych kanałach", + "includeRoles": "Tylko członkowie z tymi rolami", + "includeUsers": "Tylko ci członkowie", + "excludeChannels": "Nigdy na tych kanałach", + "excludeRoles": "Nigdy członkowie z tymi rolami", + "excludeUsers": "Nigdy ci członkowie", + "unsupportedWarning": "Ten wyzwalacz nie może ocenić tych filtrów ({{filters}}), więc reguła nigdy się nie uruchomi, dopóki są ustawione.", + "removeUnsupported": "Usuń filtry, których nie można zastosować" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/pt/rules.json b/packages/i18n/src/locales/pt/rules.json index c81d3574..eeb2fb50 100644 --- a/packages/i18n/src/locales/pt/rules.json +++ b/packages/i18n/src/locales/pt/rules.json @@ -146,7 +146,18 @@ "include": "Incluir (lista branca)", "exclude": "Excluir (lista negra)", "clearAll": "Limpar todas as condições", - "removeItem": "Remover {{label}}" + "removeItem": "Remover {{label}}", + "collapse": "Recolher condições", + "userIdInvalid": "Introduza um ID de utilizador do Discord válido (17–20 dígitos)", + "addUserId": "Adicionar ID de utilizador", + "includeChannels": "Apenas nestes canais", + "includeRoles": "Apenas membros com estas funções", + "includeUsers": "Apenas estes membros", + "excludeChannels": "Nunca nestes canais", + "excludeRoles": "Nunca membros com estas funções", + "excludeUsers": "Nunca estes membros", + "unsupportedWarning": "Este acionador não consegue avaliar estes filtros ({{filters}}), pelo que a regra nunca será executada enquanto estiverem definidos.", + "removeUnsupported": "Remover filtros que não podem ser aplicados" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ro/rules.json b/packages/i18n/src/locales/ro/rules.json index 12f8a92b..1bd62bcb 100644 --- a/packages/i18n/src/locales/ro/rules.json +++ b/packages/i18n/src/locales/ro/rules.json @@ -146,7 +146,18 @@ "include": "Includeți (lista albă)", "exclude": "Excludeți (lista neagră)", "clearAll": "Șterge toate condițiile", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Restrânge condițiile", + "userIdInvalid": "Introduceți un ID de utilizator Discord valid (17–20 cifre)", + "addUserId": "Adăugați ID-ul utilizatorului", + "includeChannels": "Doar în aceste canale", + "includeRoles": "Doar membrii cu aceste roluri", + "includeUsers": "Doar acești membri", + "excludeChannels": "Niciodată în aceste canale", + "excludeRoles": "Niciodată membrii cu aceste roluri", + "excludeUsers": "Niciodată acești membri", + "unsupportedWarning": "Acest declanșator nu poate evalua aceste filtre ({{filters}}), așa că regula nu se va executa niciodată cât timp sunt setate.", + "removeUnsupported": "Eliminați filtrele care nu se pot aplica" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ru/rules.json b/packages/i18n/src/locales/ru/rules.json index 37cb2f0b..86b33510 100644 --- a/packages/i18n/src/locales/ru/rules.json +++ b/packages/i18n/src/locales/ru/rules.json @@ -146,7 +146,18 @@ "include": "Включить (белый список)", "exclude": "Исключить (чёрный список)", "clearAll": "Очистить все условия", - "removeItem": "Удалить {{label}}" + "removeItem": "Удалить {{label}}", + "collapse": "Свернуть условия", + "userIdInvalid": "Введите действительный идентификатор пользователя Discord (17–20 цифр)", + "addUserId": "Добавить идентификатор пользователя", + "includeChannels": "Только в этих каналах", + "includeRoles": "Только участники с этими ролями", + "includeUsers": "Только эти участники", + "excludeChannels": "Никогда в этих каналах", + "excludeRoles": "Никогда участники с этими ролями", + "excludeUsers": "Никогда эти участники", + "unsupportedWarning": "Этот триггер не может вычислить эти фильтры ({{filters}}), поэтому правило никогда не сработает, пока они заданы.", + "removeUnsupported": "Удалить фильтры, которые невозможно применить" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/sk/rules.json b/packages/i18n/src/locales/sk/rules.json index 10536f1c..6bca15ba 100644 --- a/packages/i18n/src/locales/sk/rules.json +++ b/packages/i18n/src/locales/sk/rules.json @@ -146,7 +146,18 @@ "include": "Zahrnúť (biela listina)", "exclude": "Vylúčiť (čierna listina)", "clearAll": "Vymazať všetky podmienky", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Zbaliť podmienky", + "userIdInvalid": "Zadajte platné ID používateľa Discord (17 – 20 číslic)", + "addUserId": "Pridať ID používateľa", + "includeChannels": "Len v týchto kanáloch", + "includeRoles": "Len členovia s týmito rolami", + "includeUsers": "Len títo členovia", + "excludeChannels": "Nikdy v týchto kanáloch", + "excludeRoles": "Nikdy členovia s týmito rolami", + "excludeUsers": "Nikdy títo členovia", + "unsupportedWarning": "Tento spúšťač nedokáže vyhodnotiť tieto filtre ({{filters}}), takže pravidlo sa nikdy nespustí, kým sú nastavené.", + "removeUnsupported": "Odstrániť filtre, ktoré nemožno použiť" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/sl/rules.json b/packages/i18n/src/locales/sl/rules.json index 20a9d08d..1f841fe0 100644 --- a/packages/i18n/src/locales/sl/rules.json +++ b/packages/i18n/src/locales/sl/rules.json @@ -146,7 +146,18 @@ "include": "Vključi (beli seznam)", "exclude": "Izključi (črni seznam)", "clearAll": "Počisti vse pogoje", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Strni pogoje", + "userIdInvalid": "Vnesite veljaven Discord ID uporabnika (17–20 števk)", + "addUserId": "Dodaj ID uporabnika", + "includeChannels": "Samo v teh kanalih", + "includeRoles": "Samo člani s temi vlogami", + "includeUsers": "Samo ti člani", + "excludeChannels": "Nikoli v teh kanalih", + "excludeRoles": "Nikoli člani s temi vlogami", + "excludeUsers": "Nikoli ti člani", + "unsupportedWarning": "Ta sprožilec ne more oceniti teh filtrov ({{filters}}), zato se pravilo nikoli ne bo izvedlo, dokler so nastavljeni.", + "removeUnsupported": "Odstrani filtre, ki jih ni mogoče uporabiti" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/sr/rules.json b/packages/i18n/src/locales/sr/rules.json index 0ce4b8c2..cf65add6 100644 --- a/packages/i18n/src/locales/sr/rules.json +++ b/packages/i18n/src/locales/sr/rules.json @@ -146,7 +146,18 @@ "include": "Укључи (бела листа)", "exclude": "Искључи (црна листа)", "clearAll": "Обриши све услове", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Скупи услове", + "userIdInvalid": "Унесите важећи Discord кориснички ИД (17–20 цифара)", + "addUserId": "Додај кориснички ИД", + "includeChannels": "Само у овим каналима", + "includeRoles": "Само чланови са овим улогама", + "includeUsers": "Само ови чланови", + "excludeChannels": "Никада у овим каналима", + "excludeRoles": "Никада чланови са овим улогама", + "excludeUsers": "Никада ови чланови", + "unsupportedWarning": "Овај окидач не може да процени ове филтере ({{filters}}), па се правило никада неће покренути док су подешени.", + "removeUnsupported": "Уклони филтере који се не могу применити" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/sv/rules.json b/packages/i18n/src/locales/sv/rules.json index 635ebc3b..e9113b27 100644 --- a/packages/i18n/src/locales/sv/rules.json +++ b/packages/i18n/src/locales/sv/rules.json @@ -146,7 +146,18 @@ "include": "Inkludera (vitlista)", "exclude": "Exkludera (svartlista)", "clearAll": "Rensa alla villkor", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Dölj villkor", + "userIdInvalid": "Ange ett giltigt Discord-användar-ID (17–20 siffror)", + "addUserId": "Lägg till användar-ID", + "includeChannels": "Endast i dessa kanaler", + "includeRoles": "Endast medlemmar med dessa roller", + "includeUsers": "Endast dessa medlemmar", + "excludeChannels": "Aldrig i dessa kanaler", + "excludeRoles": "Aldrig medlemmar med dessa roller", + "excludeUsers": "Aldrig dessa medlemmar", + "unsupportedWarning": "Den här utlösaren kan inte utvärdera dessa filter ({{filters}}), så regeln körs aldrig så länge de är angivna.", + "removeUnsupported": "Ta bort filter som inte kan tillämpas" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/sw/rules.json b/packages/i18n/src/locales/sw/rules.json index e41532a6..77c1c1f8 100644 --- a/packages/i18n/src/locales/sw/rules.json +++ b/packages/i18n/src/locales/sw/rules.json @@ -146,7 +146,18 @@ "include": "Jumuisha (orodha nyeupe)", "exclude": "Tenga (orodha nyeusi)", "clearAll": "Futa masharti yote", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Kunja masharti", + "userIdInvalid": "Weka kitambulisho halali cha mtumiaji wa Discord (tarakimu 17–20)", + "addUserId": "Ongeza kitambulisho cha mtumiaji", + "includeChannels": "Katika chaneli hizi pekee", + "includeRoles": "Wanachama wenye majukumu haya pekee", + "includeUsers": "Wanachama hawa pekee", + "excludeChannels": "Kamwe katika chaneli hizi", + "excludeRoles": "Kamwe wanachama wenye majukumu haya", + "excludeUsers": "Kamwe wanachama hawa", + "unsupportedWarning": "Kianzishi hiki hakiwezi kutathmini vichujio hivi ({{filters}}), kwa hivyo sheria haitatekelezwa kamwe vikiwa vimewekwa.", + "removeUnsupported": "Ondoa vichujio visivyoweza kutumika" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ta/rules.json b/packages/i18n/src/locales/ta/rules.json index fa5db6d9..e520ae5b 100644 --- a/packages/i18n/src/locales/ta/rules.json +++ b/packages/i18n/src/locales/ta/rules.json @@ -146,7 +146,18 @@ "include": "சேர்க்கை (வெள்ளைப் பட்டியல்)", "exclude": "விலக்கு (கறுப்பு பட்டியல்)", "clearAll": "அனைத்து நிபந்தனைகளையும் அழி", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "நிபந்தனைகளைச் சுருக்கு", + "userIdInvalid": "சரியான Discord பயனர் ஐடியை உள்ளிடவும் (17–20 இலக்கங்கள்)", + "addUserId": "பயனர் ஐடியைச் சேர்", + "includeChannels": "இந்தச் சேனல்களில் மட்டும்", + "includeRoles": "இந்தப் பாத்திரங்களைக் கொண்ட உறுப்பினர்கள் மட்டும்", + "includeUsers": "இந்த உறுப்பினர்கள் மட்டும்", + "excludeChannels": "இந்தச் சேனல்களில் ஒருபோதும் இல்லை", + "excludeRoles": "இந்தப் பாத்திரங்களைக் கொண்ட உறுப்பினர்கள் ஒருபோதும் இல்லை", + "excludeUsers": "இந்த உறுப்பினர்கள் ஒருபோதும் இல்லை", + "unsupportedWarning": "இந்தத் தூண்டுதலால் இந்த வடிப்பான்களை ({{filters}}) மதிப்பிட முடியாது, எனவே அவை அமைக்கப்பட்டிருக்கும் வரை விதி ஒருபோதும் இயங்காது.", + "removeUnsupported": "பொருந்தாத வடிப்பான்களை அகற்று" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/th/rules.json b/packages/i18n/src/locales/th/rules.json index 111270de..b845df13 100644 --- a/packages/i18n/src/locales/th/rules.json +++ b/packages/i18n/src/locales/th/rules.json @@ -146,7 +146,18 @@ "include": "รวม (ไวท์ลิสต์)", "exclude": "ยกเว้น (แบล็กลิสต์)", "clearAll": "ล้างเงื่อนไขทั้งหมด", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "ยุบเงื่อนไข", + "userIdInvalid": "ป้อนรหัสผู้ใช้ Discord ที่ถูกต้อง (17–20 หลัก)", + "addUserId": "เพิ่มรหัสผู้ใช้", + "includeChannels": "เฉพาะในช่องเหล่านี้", + "includeRoles": "เฉพาะสมาชิกที่มีบทบาทเหล่านี้", + "includeUsers": "เฉพาะสมาชิกเหล่านี้", + "excludeChannels": "ไม่เคยในช่องเหล่านี้", + "excludeRoles": "ไม่เคยสมาชิกที่มีบทบาทเหล่านี้", + "excludeUsers": "ไม่เคยสมาชิกเหล่านี้", + "unsupportedWarning": "ทริกเกอร์นี้ไม่สามารถประเมินตัวกรองเหล่านี้ ({{filters}}) ได้ กฎจึงจะไม่ทำงานตราบใดที่ยังตั้งค่าไว้", + "removeUnsupported": "ลบตัวกรองที่ใช้ไม่ได้" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/tr/rules.json b/packages/i18n/src/locales/tr/rules.json index 67ec30d0..f05f3562 100644 --- a/packages/i18n/src/locales/tr/rules.json +++ b/packages/i18n/src/locales/tr/rules.json @@ -146,7 +146,18 @@ "include": "Dahil Et (beyaz liste)", "exclude": "Hariç Tut (kara liste)", "clearAll": "Tüm koşulları temizle", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Koşulları daralt", + "userIdInvalid": "Geçerli bir Discord kullanıcı kimliği girin (17–20 hane)", + "addUserId": "Kullanıcı kimliği ekle", + "includeChannels": "Yalnızca bu kanallarda", + "includeRoles": "Yalnızca bu rollere sahip üyeler", + "includeUsers": "Yalnızca bu üyeler", + "excludeChannels": "Bu kanallarda asla", + "excludeRoles": "Bu rollere sahip üyelerde asla", + "excludeUsers": "Bu üyelerde asla", + "unsupportedWarning": "Bu tetikleyici bu filtreleri ({{filters}}) değerlendiremez, bu nedenle filtreler ayarlı olduğu sürece kural asla çalışmaz.", + "removeUnsupported": "Uygulanamayan filtreleri kaldır" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/uk/rules.json b/packages/i18n/src/locales/uk/rules.json index e2f94229..eda068af 100644 --- a/packages/i18n/src/locales/uk/rules.json +++ b/packages/i18n/src/locales/uk/rules.json @@ -146,7 +146,18 @@ "include": "Включити (білий список)", "exclude": "Виключити (чорний список)", "clearAll": "Очистити всі умови", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Згорнути умови", + "userIdInvalid": "Введіть дійсний ідентифікатор користувача Discord (17–20 цифр)", + "addUserId": "Додати ідентифікатор користувача", + "includeChannels": "Лише в цих каналах", + "includeRoles": "Лише учасники з цими ролями", + "includeUsers": "Лише ці учасники", + "excludeChannels": "Ніколи в цих каналах", + "excludeRoles": "Ніколи учасники з цими ролями", + "excludeUsers": "Ніколи ці учасники", + "unsupportedWarning": "Цей тригер не може обчислити ці фільтри ({{filters}}), тому правило ніколи не спрацює, доки їх задано.", + "removeUnsupported": "Видалити фільтри, які неможливо застосувати" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/ur/rules.json b/packages/i18n/src/locales/ur/rules.json index 59f5f621..07acab8d 100644 --- a/packages/i18n/src/locales/ur/rules.json +++ b/packages/i18n/src/locales/ur/rules.json @@ -146,7 +146,18 @@ "include": "شامل کریں (سفید فہرست)", "exclude": "خارج کریں (کالی فہرست)", "clearAll": "تمام شرائط صاف کریں", - "removeItem": "{{label}} ہٹائیں" + "removeItem": "{{label}} ہٹائیں", + "collapse": "شرائط سمیٹیں", + "userIdInvalid": "درست Discord صارف شناخت درج کریں (17–20 ہندسے)", + "addUserId": "صارف شناخت شامل کریں", + "includeChannels": "صرف ان چینلز میں", + "includeRoles": "صرف ان کرداروں والے اراکین", + "includeUsers": "صرف یہ اراکین", + "excludeChannels": "ان چینلز میں کبھی نہیں", + "excludeRoles": "ان کرداروں والے اراکین کبھی نہیں", + "excludeUsers": "یہ اراکین کبھی نہیں", + "unsupportedWarning": "یہ ٹریگر ان فلٹرز ({{filters}}) کا جائزہ نہیں لے سکتا، اس لیے جب تک یہ مقرر ہیں قاعدہ کبھی نہیں چلے گا۔", + "removeUnsupported": "وہ فلٹرز ہٹائیں جو لاگو نہیں ہو سکتے" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/vi/rules.json b/packages/i18n/src/locales/vi/rules.json index 949e58c8..a8b29fec 100644 --- a/packages/i18n/src/locales/vi/rules.json +++ b/packages/i18n/src/locales/vi/rules.json @@ -146,7 +146,18 @@ "include": "Bao Gồm (danh sách trắng)", "exclude": "Loại Trừ (danh sách đen)", "clearAll": "Xóa tất cả điều kiện", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "Thu gọn điều kiện", + "userIdInvalid": "Nhập ID người dùng Discord hợp lệ (17–20 chữ số)", + "addUserId": "Thêm ID người dùng", + "includeChannels": "Chỉ trong các kênh này", + "includeRoles": "Chỉ thành viên có các vai trò này", + "includeUsers": "Chỉ những thành viên này", + "excludeChannels": "Không bao giờ trong các kênh này", + "excludeRoles": "Không bao giờ thành viên có các vai trò này", + "excludeUsers": "Không bao giờ những thành viên này", + "unsupportedWarning": "Trình kích hoạt này không thể đánh giá các bộ lọc này ({{filters}}), nên quy tắc sẽ không bao giờ chạy khi chúng còn được đặt.", + "removeUnsupported": "Xóa các bộ lọc không thể áp dụng" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/zh-CN/rules.json b/packages/i18n/src/locales/zh-CN/rules.json index 01af703f..a58fd9b5 100644 --- a/packages/i18n/src/locales/zh-CN/rules.json +++ b/packages/i18n/src/locales/zh-CN/rules.json @@ -146,7 +146,18 @@ "include": "包含(白名单)", "exclude": "排除(黑名单)", "clearAll": "清除所有条件", - "removeItem": "移除 {{label}}" + "removeItem": "移除 {{label}}", + "collapse": "收起条件", + "userIdInvalid": "请输入有效的 Discord 用户 ID(17–20 位数字)", + "addUserId": "添加用户 ID", + "includeChannels": "仅在这些频道", + "includeRoles": "仅拥有这些身份组的成员", + "includeUsers": "仅这些成员", + "excludeChannels": "从不在这些频道", + "excludeRoles": "从不对拥有这些身份组的成员", + "excludeUsers": "从不对这些成员", + "unsupportedWarning": "此触发器无法评估这些筛选条件({{filters}}),因此只要它们处于设置状态,规则将永远不会触发。", + "removeUnsupported": "移除无法生效的筛选条件" }, "workflow": { "errors": { diff --git a/packages/i18n/src/locales/zh-TW/rules.json b/packages/i18n/src/locales/zh-TW/rules.json index 08381df6..b8c4bf9e 100644 --- a/packages/i18n/src/locales/zh-TW/rules.json +++ b/packages/i18n/src/locales/zh-TW/rules.json @@ -146,7 +146,18 @@ "include": "包含(白名單)", "exclude": "排除(黑名單)", "clearAll": "清除所有條件", - "removeItem": "Remove {{label}}" + "removeItem": "Remove {{label}}", + "collapse": "收合條件", + "userIdInvalid": "請輸入有效的 Discord 使用者 ID(17–20 位數字)", + "addUserId": "新增使用者 ID", + "includeChannels": "僅在這些頻道", + "includeRoles": "僅擁有這些身分組的成員", + "includeUsers": "僅這些成員", + "excludeChannels": "從不在這些頻道", + "excludeRoles": "從不對擁有這些身分組的成員", + "excludeUsers": "從不對這些成員", + "unsupportedWarning": "此觸發器無法評估這些篩選條件({{filters}}),因此只要它們處於設定狀態,規則將永遠不會觸發。", + "removeUnsupported": "移除無法生效的篩選條件" }, "workflow": { "errors": { From fa1026fe6b3e57ad1dae602798119bd3cd344d57 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 22:13:58 +0300 Subject: [PATCH 07/19] i18n(rules): translate the condition chip remove label in 38 locales `conditions.removeItem` is the aria-label on each filter chip's remove button ("Remove {{label}}"). It had shipped English in 38 of 48 locales, so screen-reader users in those languages heard an English control name inside an otherwise translated panel. Pre-existing, but in the exact block the trigger-filter work touches, so it is fixed here rather than left as a known gap. The remaining identical-to-English strings (es/gl "Roles", fr "Conditions"/"active") are correct in those languages, not leaks. Every file is a one-line change. Co-Authored-By: Claude Opus 5 (1M context) --- packages/i18n/src/locales/af/rules.json | 2 +- packages/i18n/src/locales/bg/rules.json | 2 +- packages/i18n/src/locales/bn/rules.json | 2 +- packages/i18n/src/locales/ca/rules.json | 2 +- packages/i18n/src/locales/cs/rules.json | 2 +- packages/i18n/src/locales/da/rules.json | 2 +- packages/i18n/src/locales/el/rules.json | 2 +- packages/i18n/src/locales/et/rules.json | 2 +- packages/i18n/src/locales/eu/rules.json | 2 +- packages/i18n/src/locales/fa/rules.json | 2 +- packages/i18n/src/locales/fi/rules.json | 2 +- packages/i18n/src/locales/fil/rules.json | 2 +- packages/i18n/src/locales/gl/rules.json | 2 +- packages/i18n/src/locales/he/rules.json | 2 +- packages/i18n/src/locales/hi/rules.json | 2 +- packages/i18n/src/locales/hr/rules.json | 2 +- packages/i18n/src/locales/hu/rules.json | 2 +- packages/i18n/src/locales/id/rules.json | 2 +- packages/i18n/src/locales/it/rules.json | 2 +- packages/i18n/src/locales/ko/rules.json | 2 +- packages/i18n/src/locales/lt/rules.json | 2 +- packages/i18n/src/locales/lv/rules.json | 2 +- packages/i18n/src/locales/ms/rules.json | 2 +- packages/i18n/src/locales/nl/rules.json | 2 +- packages/i18n/src/locales/no/rules.json | 2 +- packages/i18n/src/locales/pl/rules.json | 2 +- packages/i18n/src/locales/ro/rules.json | 2 +- packages/i18n/src/locales/sk/rules.json | 2 +- packages/i18n/src/locales/sl/rules.json | 2 +- packages/i18n/src/locales/sr/rules.json | 2 +- packages/i18n/src/locales/sv/rules.json | 2 +- packages/i18n/src/locales/sw/rules.json | 2 +- packages/i18n/src/locales/ta/rules.json | 2 +- packages/i18n/src/locales/th/rules.json | 2 +- packages/i18n/src/locales/tr/rules.json | 2 +- packages/i18n/src/locales/uk/rules.json | 2 +- packages/i18n/src/locales/vi/rules.json | 2 +- packages/i18n/src/locales/zh-TW/rules.json | 2 +- 38 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/i18n/src/locales/af/rules.json b/packages/i18n/src/locales/af/rules.json index 36009da2..3b46bfad 100644 --- a/packages/i18n/src/locales/af/rules.json +++ b/packages/i18n/src/locales/af/rules.json @@ -146,7 +146,7 @@ "include": "Sluit In (witlys)", "exclude": "Sluit Uit (swartelys)", "clearAll": "Maak alle voorwaardes skoon", - "removeItem": "Remove {{label}}", + "removeItem": "Verwyder {{label}}", "collapse": "Vou voorwaardes in", "userIdInvalid": "Voer 'n geldige Discord-gebruiker-ID in (17–20 syfers)", "addUserId": "Voeg gebruiker-ID by", diff --git a/packages/i18n/src/locales/bg/rules.json b/packages/i18n/src/locales/bg/rules.json index 82f07c7e..52e6a4f5 100644 --- a/packages/i18n/src/locales/bg/rules.json +++ b/packages/i18n/src/locales/bg/rules.json @@ -146,7 +146,7 @@ "include": "Включи (бял списък)", "exclude": "Изключи (черен списък)", "clearAll": "Изчисти всички условия", - "removeItem": "Remove {{label}}", + "removeItem": "Премахване на {{label}}", "collapse": "Свиване на условията", "userIdInvalid": "Въведете валиден Discord потребителски идентификатор (17–20 цифри)", "addUserId": "Добавяне на потребителски идентификатор", diff --git a/packages/i18n/src/locales/bn/rules.json b/packages/i18n/src/locales/bn/rules.json index bd783306..277db2fb 100644 --- a/packages/i18n/src/locales/bn/rules.json +++ b/packages/i18n/src/locales/bn/rules.json @@ -146,7 +146,7 @@ "include": "অন্তর্ভুক্ত (হোয়াইটলিস্ট)", "exclude": "বর্জন (ব্ল্যাকলিস্ট)", "clearAll": "সমস্ত শর্ত সাফ করুন", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} সরান", "collapse": "শর্তগুলি সংকুচিত করুন", "userIdInvalid": "একটি বৈধ Discord ব্যবহারকারী আইডি লিখুন (১৭–২০ সংখ্যা)", "addUserId": "ব্যবহারকারী আইডি যোগ করুন", diff --git a/packages/i18n/src/locales/ca/rules.json b/packages/i18n/src/locales/ca/rules.json index 31104990..8c11f3ac 100644 --- a/packages/i18n/src/locales/ca/rules.json +++ b/packages/i18n/src/locales/ca/rules.json @@ -146,7 +146,7 @@ "include": "Incloure (llista blanca)", "exclude": "Excloure (llista negra)", "clearAll": "Netejar totes les condicions", - "removeItem": "Remove {{label}}", + "removeItem": "Elimina {{label}}", "collapse": "Replega les condicions", "userIdInvalid": "Introdueix un ID d'usuari de Discord vàlid (17–20 dígits)", "addUserId": "Afegeix un ID d'usuari", diff --git a/packages/i18n/src/locales/cs/rules.json b/packages/i18n/src/locales/cs/rules.json index eb7025dd..955948d2 100644 --- a/packages/i18n/src/locales/cs/rules.json +++ b/packages/i18n/src/locales/cs/rules.json @@ -146,7 +146,7 @@ "include": "Zahrnout (bílá listina)", "exclude": "Vyloučit (černá listina)", "clearAll": "Vymazat všechny podmínky", - "removeItem": "Remove {{label}}", + "removeItem": "Odebrat {{label}}", "collapse": "Sbalit podmínky", "userIdInvalid": "Zadejte platné Discord ID uživatele (17–20 číslic)", "addUserId": "Přidat ID uživatele", diff --git a/packages/i18n/src/locales/da/rules.json b/packages/i18n/src/locales/da/rules.json index d893af56..3c50761b 100644 --- a/packages/i18n/src/locales/da/rules.json +++ b/packages/i18n/src/locales/da/rules.json @@ -146,7 +146,7 @@ "include": "Inkluder (hvidliste)", "exclude": "Ekskluder (sortliste)", "clearAll": "Ryd alle betingelser", - "removeItem": "Remove {{label}}", + "removeItem": "Fjern {{label}}", "collapse": "Skjul betingelser", "userIdInvalid": "Indtast et gyldigt Discord-bruger-id (17–20 cifre)", "addUserId": "Tilføj bruger-id", diff --git a/packages/i18n/src/locales/el/rules.json b/packages/i18n/src/locales/el/rules.json index eafaf251..f85b5db1 100644 --- a/packages/i18n/src/locales/el/rules.json +++ b/packages/i18n/src/locales/el/rules.json @@ -146,7 +146,7 @@ "include": "Συμπερίληψη (λευκή λίστα)", "exclude": "Εξαίρεση (μαύρη λίστα)", "clearAll": "Εκκαθάριση όλων των συνθηκών", - "removeItem": "Remove {{label}}", + "removeItem": "Κατάργηση {{label}}", "collapse": "Σύμπτυξη συνθηκών", "userIdInvalid": "Εισαγάγετε έγκυρο αναγνωριστικό χρήστη Discord (17–20 ψηφία)", "addUserId": "Προσθήκη αναγνωριστικού χρήστη", diff --git a/packages/i18n/src/locales/et/rules.json b/packages/i18n/src/locales/et/rules.json index d8b0d5b2..8328226b 100644 --- a/packages/i18n/src/locales/et/rules.json +++ b/packages/i18n/src/locales/et/rules.json @@ -146,7 +146,7 @@ "include": "Kaasa (valge nimekiri)", "exclude": "Välista (must nimekiri)", "clearAll": "Tühjenda kõik tingimused", - "removeItem": "Remove {{label}}", + "removeItem": "Eemalda {{label}}", "collapse": "Ahenda tingimused", "userIdInvalid": "Sisesta kehtiv Discordi kasutaja ID (17–20 numbrit)", "addUserId": "Lisa kasutaja ID", diff --git a/packages/i18n/src/locales/eu/rules.json b/packages/i18n/src/locales/eu/rules.json index 1780536e..38321514 100644 --- a/packages/i18n/src/locales/eu/rules.json +++ b/packages/i18n/src/locales/eu/rules.json @@ -146,7 +146,7 @@ "include": "Barne hartu (zerrenda zuria)", "exclude": "Baztertu (zerrenda beltza)", "clearAll": "Baldintza guztiak garbitu", - "removeItem": "Remove {{label}}", + "removeItem": "Kendu {{label}}", "collapse": "Tolestu baldintzak", "userIdInvalid": "Sartu baliozko Discord erabiltzaile ID bat (17–20 digitu)", "addUserId": "Gehitu erabiltzaile IDa", diff --git a/packages/i18n/src/locales/fa/rules.json b/packages/i18n/src/locales/fa/rules.json index f5dac1b8..83fe673e 100644 --- a/packages/i18n/src/locales/fa/rules.json +++ b/packages/i18n/src/locales/fa/rules.json @@ -146,7 +146,7 @@ "include": "شامل‌کردن (فهرست سفید)", "exclude": "حذف (فهرست سیاه)", "clearAll": "پاک کردن همه شرایط", - "removeItem": "Remove {{label}}", + "removeItem": "حذف {{label}}", "collapse": "جمع کردن شرط‌ها", "userIdInvalid": "یک شناسه کاربری معتبر Discord وارد کنید (۱۷ تا ۲۰ رقم)", "addUserId": "افزودن شناسه کاربر", diff --git a/packages/i18n/src/locales/fi/rules.json b/packages/i18n/src/locales/fi/rules.json index b95ee3a0..b778f2e9 100644 --- a/packages/i18n/src/locales/fi/rules.json +++ b/packages/i18n/src/locales/fi/rules.json @@ -146,7 +146,7 @@ "include": "Sisällytä (valkolista)", "exclude": "Sulje pois (mustat lista)", "clearAll": "Tyhjennä kaikki ehdot", - "removeItem": "Remove {{label}}", + "removeItem": "Poista {{label}}", "collapse": "Tiivistä ehdot", "userIdInvalid": "Anna kelvollinen Discord-käyttäjätunnus (17–20 numeroa)", "addUserId": "Lisää käyttäjätunnus", diff --git a/packages/i18n/src/locales/fil/rules.json b/packages/i18n/src/locales/fil/rules.json index 84469d85..41b87c16 100644 --- a/packages/i18n/src/locales/fil/rules.json +++ b/packages/i18n/src/locales/fil/rules.json @@ -146,7 +146,7 @@ "include": "Isama (whitelist)", "exclude": "I-exclude (blacklist)", "clearAll": "I-clear ang lahat ng kondisyon", - "removeItem": "Remove {{label}}", + "removeItem": "Alisin ang {{label}}", "collapse": "I-collapse ang mga kundisyon", "userIdInvalid": "Maglagay ng wastong Discord user ID (17–20 digit)", "addUserId": "Magdagdag ng user ID", diff --git a/packages/i18n/src/locales/gl/rules.json b/packages/i18n/src/locales/gl/rules.json index 6d4d846f..98eba3e4 100644 --- a/packages/i18n/src/locales/gl/rules.json +++ b/packages/i18n/src/locales/gl/rules.json @@ -146,7 +146,7 @@ "include": "Incluír (lista branca)", "exclude": "Excluír (lista negra)", "clearAll": "Limpar todas as condicións", - "removeItem": "Remove {{label}}", + "removeItem": "Eliminar {{label}}", "collapse": "Contraer condicións", "userIdInvalid": "Introduce un ID de usuario de Discord válido (17–20 díxitos)", "addUserId": "Engadir ID de usuario", diff --git a/packages/i18n/src/locales/he/rules.json b/packages/i18n/src/locales/he/rules.json index 4bb5964c..05d05aa3 100644 --- a/packages/i18n/src/locales/he/rules.json +++ b/packages/i18n/src/locales/he/rules.json @@ -146,7 +146,7 @@ "include": "כלול (רשימה לבנה)", "exclude": "אל תכלול (רשימה שחורה)", "clearAll": "נקה את כל התנאים", - "removeItem": "Remove {{label}}", + "removeItem": "הסר את {{label}}", "collapse": "כווץ תנאים", "userIdInvalid": "הזן מזהה משתמש Discord תקין (17–20 ספרות)", "addUserId": "הוסף מזהה משתמש", diff --git a/packages/i18n/src/locales/hi/rules.json b/packages/i18n/src/locales/hi/rules.json index ad9f8dc3..3dd54b95 100644 --- a/packages/i18n/src/locales/hi/rules.json +++ b/packages/i18n/src/locales/hi/rules.json @@ -146,7 +146,7 @@ "include": "शामिल करें (व्हाइटलिस्ट)", "exclude": "बाहर करें (ब्लैकलिस्ट)", "clearAll": "सभी शर्तें साफ़ करें", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} हटाएँ", "collapse": "शर्तें संक्षिप्त करें", "userIdInvalid": "मान्य Discord उपयोगकर्ता आईडी दर्ज करें (17–20 अंक)", "addUserId": "उपयोगकर्ता आईडी जोड़ें", diff --git a/packages/i18n/src/locales/hr/rules.json b/packages/i18n/src/locales/hr/rules.json index 9d5185a5..2a32aff0 100644 --- a/packages/i18n/src/locales/hr/rules.json +++ b/packages/i18n/src/locales/hr/rules.json @@ -146,7 +146,7 @@ "include": "Uključi (bijela lista)", "exclude": "Isključi (crna lista)", "clearAll": "Obriši sve uvjete", - "removeItem": "Remove {{label}}", + "removeItem": "Ukloni {{label}}", "collapse": "Sažmi uvjete", "userIdInvalid": "Unesite valjani Discord korisnički ID (17–20 znamenki)", "addUserId": "Dodaj korisnički ID", diff --git a/packages/i18n/src/locales/hu/rules.json b/packages/i18n/src/locales/hu/rules.json index 80874a69..744176a9 100644 --- a/packages/i18n/src/locales/hu/rules.json +++ b/packages/i18n/src/locales/hu/rules.json @@ -146,7 +146,7 @@ "include": "Belefoglalás (fehérlista)", "exclude": "Kizárás (feketelista)", "clearAll": "Összes feltétel törlése", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} eltávolítása", "collapse": "Feltételek összecsukása", "userIdInvalid": "Adjon meg érvényes Discord felhasználói azonosítót (17–20 számjegy)", "addUserId": "Felhasználói azonosító hozzáadása", diff --git a/packages/i18n/src/locales/id/rules.json b/packages/i18n/src/locales/id/rules.json index d081e9f6..6be6c2b7 100644 --- a/packages/i18n/src/locales/id/rules.json +++ b/packages/i18n/src/locales/id/rules.json @@ -146,7 +146,7 @@ "include": "Sertakan (daftar putih)", "exclude": "Kecualikan (daftar hitam)", "clearAll": "Hapus semua kondisi", - "removeItem": "Remove {{label}}", + "removeItem": "Hapus {{label}}", "collapse": "Ciutkan kondisi", "userIdInvalid": "Masukkan ID pengguna Discord yang valid (17–20 digit)", "addUserId": "Tambahkan ID pengguna", diff --git a/packages/i18n/src/locales/it/rules.json b/packages/i18n/src/locales/it/rules.json index c9380754..44952c32 100644 --- a/packages/i18n/src/locales/it/rules.json +++ b/packages/i18n/src/locales/it/rules.json @@ -146,7 +146,7 @@ "include": "Includi (whitelist)", "exclude": "Escludi (blacklist)", "clearAll": "Cancella tutte le condizioni", - "removeItem": "Remove {{label}}", + "removeItem": "Rimuovi {{label}}", "collapse": "Comprimi condizioni", "userIdInvalid": "Inserisci un ID utente Discord valido (17–20 cifre)", "addUserId": "Aggiungi ID utente", diff --git a/packages/i18n/src/locales/ko/rules.json b/packages/i18n/src/locales/ko/rules.json index 6ae4853a..38e04408 100644 --- a/packages/i18n/src/locales/ko/rules.json +++ b/packages/i18n/src/locales/ko/rules.json @@ -146,7 +146,7 @@ "include": "포함 (화이트리스트)", "exclude": "제외 (블랙리스트)", "clearAll": "모든 조건 초기화", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} 제거", "collapse": "조건 접기", "userIdInvalid": "유효한 Discord 사용자 ID를 입력하세요 (17~20자리)", "addUserId": "사용자 ID 추가", diff --git a/packages/i18n/src/locales/lt/rules.json b/packages/i18n/src/locales/lt/rules.json index 3ce96c4a..5ebbbf29 100644 --- a/packages/i18n/src/locales/lt/rules.json +++ b/packages/i18n/src/locales/lt/rules.json @@ -146,7 +146,7 @@ "include": "Įtraukti (baltasis sąrašas)", "exclude": "Neįtraukti (juodasis sąrašas)", "clearAll": "Išvalyti visas sąlygas", - "removeItem": "Remove {{label}}", + "removeItem": "Pašalinti {{label}}", "collapse": "Suskleisti sąlygas", "userIdInvalid": "Įveskite galiojantį Discord naudotojo ID (17–20 skaitmenų)", "addUserId": "Pridėti naudotojo ID", diff --git a/packages/i18n/src/locales/lv/rules.json b/packages/i18n/src/locales/lv/rules.json index d206ce6a..f12e18cd 100644 --- a/packages/i18n/src/locales/lv/rules.json +++ b/packages/i18n/src/locales/lv/rules.json @@ -146,7 +146,7 @@ "include": "Iekļaut (baltais saraksts)", "exclude": "Izslēgt (melnais saraksts)", "clearAll": "Notīrīt visus nosacījumus", - "removeItem": "Remove {{label}}", + "removeItem": "Noņemt {{label}}", "collapse": "Sakļaut nosacījumus", "userIdInvalid": "Ievadiet derīgu Discord lietotāja ID (17–20 cipari)", "addUserId": "Pievienot lietotāja ID", diff --git a/packages/i18n/src/locales/ms/rules.json b/packages/i18n/src/locales/ms/rules.json index a043864b..5adf4a4c 100644 --- a/packages/i18n/src/locales/ms/rules.json +++ b/packages/i18n/src/locales/ms/rules.json @@ -146,7 +146,7 @@ "include": "Sertakan (senarai putih)", "exclude": "Kecualikan (senarai hitam)", "clearAll": "Kosongkan semua syarat", - "removeItem": "Remove {{label}}", + "removeItem": "Buang {{label}}", "collapse": "Kuncupkan syarat", "userIdInvalid": "Masukkan ID pengguna Discord yang sah (17–20 digit)", "addUserId": "Tambah ID pengguna", diff --git a/packages/i18n/src/locales/nl/rules.json b/packages/i18n/src/locales/nl/rules.json index 22c9be82..802998ae 100644 --- a/packages/i18n/src/locales/nl/rules.json +++ b/packages/i18n/src/locales/nl/rules.json @@ -146,7 +146,7 @@ "include": "Insluiten (whitelist)", "exclude": "Uitsluiten (blacklist)", "clearAll": "Alle voorwaarden wissen", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} verwijderen", "collapse": "Voorwaarden inklappen", "userIdInvalid": "Voer een geldige Discord-gebruikers-ID in (17–20 cijfers)", "addUserId": "Gebruikers-ID toevoegen", diff --git a/packages/i18n/src/locales/no/rules.json b/packages/i18n/src/locales/no/rules.json index 6289e5f2..9e2bac01 100644 --- a/packages/i18n/src/locales/no/rules.json +++ b/packages/i18n/src/locales/no/rules.json @@ -146,7 +146,7 @@ "include": "Inkluder (hvitliste)", "exclude": "Ekskluder (svarteliste)", "clearAll": "Tøm alle betingelser", - "removeItem": "Remove {{label}}", + "removeItem": "Fjern {{label}}", "collapse": "Skjul betingelser", "userIdInvalid": "Skriv inn en gyldig Discord-bruker-ID (17–20 sifre)", "addUserId": "Legg til bruker-ID", diff --git a/packages/i18n/src/locales/pl/rules.json b/packages/i18n/src/locales/pl/rules.json index 91e76bb0..25f60a0a 100644 --- a/packages/i18n/src/locales/pl/rules.json +++ b/packages/i18n/src/locales/pl/rules.json @@ -146,7 +146,7 @@ "include": "Dołącz (biała lista)", "exclude": "Wyklucz (czarna lista)", "clearAll": "Wyczyść wszystkie warunki", - "removeItem": "Remove {{label}}", + "removeItem": "Usuń {{label}}", "collapse": "Zwiń warunki", "userIdInvalid": "Wprowadź prawidłowy identyfikator użytkownika Discord (17–20 cyfr)", "addUserId": "Dodaj identyfikator użytkownika", diff --git a/packages/i18n/src/locales/ro/rules.json b/packages/i18n/src/locales/ro/rules.json index 1bd62bcb..ef867620 100644 --- a/packages/i18n/src/locales/ro/rules.json +++ b/packages/i18n/src/locales/ro/rules.json @@ -146,7 +146,7 @@ "include": "Includeți (lista albă)", "exclude": "Excludeți (lista neagră)", "clearAll": "Șterge toate condițiile", - "removeItem": "Remove {{label}}", + "removeItem": "Elimină {{label}}", "collapse": "Restrânge condițiile", "userIdInvalid": "Introduceți un ID de utilizator Discord valid (17–20 cifre)", "addUserId": "Adăugați ID-ul utilizatorului", diff --git a/packages/i18n/src/locales/sk/rules.json b/packages/i18n/src/locales/sk/rules.json index 6bca15ba..ff0fc249 100644 --- a/packages/i18n/src/locales/sk/rules.json +++ b/packages/i18n/src/locales/sk/rules.json @@ -146,7 +146,7 @@ "include": "Zahrnúť (biela listina)", "exclude": "Vylúčiť (čierna listina)", "clearAll": "Vymazať všetky podmienky", - "removeItem": "Remove {{label}}", + "removeItem": "Odstrániť {{label}}", "collapse": "Zbaliť podmienky", "userIdInvalid": "Zadajte platné ID používateľa Discord (17 – 20 číslic)", "addUserId": "Pridať ID používateľa", diff --git a/packages/i18n/src/locales/sl/rules.json b/packages/i18n/src/locales/sl/rules.json index 1f841fe0..c9a7fa90 100644 --- a/packages/i18n/src/locales/sl/rules.json +++ b/packages/i18n/src/locales/sl/rules.json @@ -146,7 +146,7 @@ "include": "Vključi (beli seznam)", "exclude": "Izključi (črni seznam)", "clearAll": "Počisti vse pogoje", - "removeItem": "Remove {{label}}", + "removeItem": "Odstrani {{label}}", "collapse": "Strni pogoje", "userIdInvalid": "Vnesite veljaven Discord ID uporabnika (17–20 števk)", "addUserId": "Dodaj ID uporabnika", diff --git a/packages/i18n/src/locales/sr/rules.json b/packages/i18n/src/locales/sr/rules.json index cf65add6..217d127d 100644 --- a/packages/i18n/src/locales/sr/rules.json +++ b/packages/i18n/src/locales/sr/rules.json @@ -146,7 +146,7 @@ "include": "Укључи (бела листа)", "exclude": "Искључи (црна листа)", "clearAll": "Обриши све услове", - "removeItem": "Remove {{label}}", + "removeItem": "Уклони {{label}}", "collapse": "Скупи услове", "userIdInvalid": "Унесите важећи Discord кориснички ИД (17–20 цифара)", "addUserId": "Додај кориснички ИД", diff --git a/packages/i18n/src/locales/sv/rules.json b/packages/i18n/src/locales/sv/rules.json index e9113b27..6df7cd5d 100644 --- a/packages/i18n/src/locales/sv/rules.json +++ b/packages/i18n/src/locales/sv/rules.json @@ -146,7 +146,7 @@ "include": "Inkludera (vitlista)", "exclude": "Exkludera (svartlista)", "clearAll": "Rensa alla villkor", - "removeItem": "Remove {{label}}", + "removeItem": "Ta bort {{label}}", "collapse": "Dölj villkor", "userIdInvalid": "Ange ett giltigt Discord-användar-ID (17–20 siffror)", "addUserId": "Lägg till användar-ID", diff --git a/packages/i18n/src/locales/sw/rules.json b/packages/i18n/src/locales/sw/rules.json index 77c1c1f8..94203829 100644 --- a/packages/i18n/src/locales/sw/rules.json +++ b/packages/i18n/src/locales/sw/rules.json @@ -146,7 +146,7 @@ "include": "Jumuisha (orodha nyeupe)", "exclude": "Tenga (orodha nyeusi)", "clearAll": "Futa masharti yote", - "removeItem": "Remove {{label}}", + "removeItem": "Ondoa {{label}}", "collapse": "Kunja masharti", "userIdInvalid": "Weka kitambulisho halali cha mtumiaji wa Discord (tarakimu 17–20)", "addUserId": "Ongeza kitambulisho cha mtumiaji", diff --git a/packages/i18n/src/locales/ta/rules.json b/packages/i18n/src/locales/ta/rules.json index e520ae5b..120aec7e 100644 --- a/packages/i18n/src/locales/ta/rules.json +++ b/packages/i18n/src/locales/ta/rules.json @@ -146,7 +146,7 @@ "include": "சேர்க்கை (வெள்ளைப் பட்டியல்)", "exclude": "விலக்கு (கறுப்பு பட்டியல்)", "clearAll": "அனைத்து நிபந்தனைகளையும் அழி", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} அகற்று", "collapse": "நிபந்தனைகளைச் சுருக்கு", "userIdInvalid": "சரியான Discord பயனர் ஐடியை உள்ளிடவும் (17–20 இலக்கங்கள்)", "addUserId": "பயனர் ஐடியைச் சேர்", diff --git a/packages/i18n/src/locales/th/rules.json b/packages/i18n/src/locales/th/rules.json index b845df13..ce517541 100644 --- a/packages/i18n/src/locales/th/rules.json +++ b/packages/i18n/src/locales/th/rules.json @@ -146,7 +146,7 @@ "include": "รวม (ไวท์ลิสต์)", "exclude": "ยกเว้น (แบล็กลิสต์)", "clearAll": "ล้างเงื่อนไขทั้งหมด", - "removeItem": "Remove {{label}}", + "removeItem": "ลบ {{label}}", "collapse": "ยุบเงื่อนไข", "userIdInvalid": "ป้อนรหัสผู้ใช้ Discord ที่ถูกต้อง (17–20 หลัก)", "addUserId": "เพิ่มรหัสผู้ใช้", diff --git a/packages/i18n/src/locales/tr/rules.json b/packages/i18n/src/locales/tr/rules.json index f05f3562..3feaf7ad 100644 --- a/packages/i18n/src/locales/tr/rules.json +++ b/packages/i18n/src/locales/tr/rules.json @@ -146,7 +146,7 @@ "include": "Dahil Et (beyaz liste)", "exclude": "Hariç Tut (kara liste)", "clearAll": "Tüm koşulları temizle", - "removeItem": "Remove {{label}}", + "removeItem": "{{label}} öğesini kaldır", "collapse": "Koşulları daralt", "userIdInvalid": "Geçerli bir Discord kullanıcı kimliği girin (17–20 hane)", "addUserId": "Kullanıcı kimliği ekle", diff --git a/packages/i18n/src/locales/uk/rules.json b/packages/i18n/src/locales/uk/rules.json index eda068af..7a2e3b14 100644 --- a/packages/i18n/src/locales/uk/rules.json +++ b/packages/i18n/src/locales/uk/rules.json @@ -146,7 +146,7 @@ "include": "Включити (білий список)", "exclude": "Виключити (чорний список)", "clearAll": "Очистити всі умови", - "removeItem": "Remove {{label}}", + "removeItem": "Видалити {{label}}", "collapse": "Згорнути умови", "userIdInvalid": "Введіть дійсний ідентифікатор користувача Discord (17–20 цифр)", "addUserId": "Додати ідентифікатор користувача", diff --git a/packages/i18n/src/locales/vi/rules.json b/packages/i18n/src/locales/vi/rules.json index a8b29fec..73f78249 100644 --- a/packages/i18n/src/locales/vi/rules.json +++ b/packages/i18n/src/locales/vi/rules.json @@ -146,7 +146,7 @@ "include": "Bao Gồm (danh sách trắng)", "exclude": "Loại Trừ (danh sách đen)", "clearAll": "Xóa tất cả điều kiện", - "removeItem": "Remove {{label}}", + "removeItem": "Xóa {{label}}", "collapse": "Thu gọn điều kiện", "userIdInvalid": "Nhập ID người dùng Discord hợp lệ (17–20 chữ số)", "addUserId": "Thêm ID người dùng", diff --git a/packages/i18n/src/locales/zh-TW/rules.json b/packages/i18n/src/locales/zh-TW/rules.json index b8c4bf9e..64f98926 100644 --- a/packages/i18n/src/locales/zh-TW/rules.json +++ b/packages/i18n/src/locales/zh-TW/rules.json @@ -146,7 +146,7 @@ "include": "包含(白名單)", "exclude": "排除(黑名單)", "clearAll": "清除所有條件", - "removeItem": "Remove {{label}}", + "removeItem": "移除 {{label}}", "collapse": "收合條件", "userIdInvalid": "請輸入有效的 Discord 使用者 ID(17–20 位數字)", "addUserId": "新增使用者 ID", From 6cb8b6ec39ab555f634477af8ea3c7e67751bd6f Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 22:25:54 +0300 Subject: [PATCH 08/19] fix(automation): stop reporting phantom successes at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processEvent cannot tell "returned because there was nothing to do" from "succeeded", and every executor returned silently when it could not act. The ActionLog therefore recorded success for actions that never ran: a moderator whose auto-role rule did nothing saw "214 executions, 100% success" and had nothing to debug with. Executors now throw, with the reason, so the caller's existing catch logs success:false: - missing required config (channelId, message, roleId, nickname, emoji…) - an unresolvable / non-text / invisible channel - a DM that Discord refused, a nickname change without permission - an invalid emoji, which previously only warned addRole/removeRole additionally resolve the member from the guild when the context has none, the way setNickname already did. This is what broke the single most common automation people build — "react to this message, get a role" — since a reaction context carries no member. Condition steps evaluate hasRole/notHasRole BEFORE reading condition.field: those operators never consume the field, so the canonical "if member has @Verified" branch always took the else path because the field defaults to channelId and memberJoin has none. Step mode's unknown-action-type branch warned and then fell through to the success log; it now throws. sendWebhook: follow redirects by hand so every hop is re-checked against the private-address rules (a public host answering "302 -> http://169.254.169.254/" previously walked straight into cloud metadata), cap at 3 hops, cap the drained response body at 64 KiB instead of buffering whatever the remote sends, and treat a non-2xx as a failure. +27 tests. Bot suite 400 passing; the 5 guildMemberAdd failures are pre-existing on the merge-base (that suite hits a real Postgres). Co-Authored-By: Claude Opus 5 (1M context) --- .../features/automation/system/executor.ts | 28 +- .../features/automation/system/registry.ts | 281 ++++++++++++------ .../automation/system/executor.test.ts | 134 +++++++++ .../automation/system/registry-emoji.test.ts | 26 +- .../system/registry-failures.test.ts | 174 +++++++++++ .../system/registry-webhook-safety.test.ts | 156 ++++++++++ 6 files changed, 684 insertions(+), 115 deletions(-) create mode 100644 apps/bot/tests/features/automation/system/registry-failures.test.ts create mode 100644 apps/bot/tests/features/automation/system/registry-webhook-safety.test.ts diff --git a/apps/bot/src/features/automation/system/executor.ts b/apps/bot/src/features/automation/system/executor.ts index 3e50a275..038fd521 100644 --- a/apps/bot/src/features/automation/system/executor.ts +++ b/apps/bot/src/features/automation/system/executor.ts @@ -132,9 +132,21 @@ function evaluateCondition( condition: StepConditionConfig, context: EventContext, ): boolean { - const actual = getContextValue(condition.field, context); const expected = condition.value; + // Role operators are answered entirely by the member's roles — they never + // consume `condition.field`. Checking the field first meant the canonical + // "if member has @Verified" branch always took the else path, because the + // field defaults to channelId and memberJoin never populates one. + if (condition.operator === "hasRole") { + return context.member?.roles.cache.has(expected) ?? false; + } + if (condition.operator === "notHasRole") { + if (!context.member) return false; + return !context.member.roles.cache.has(expected); + } + + const actual = getContextValue(condition.field, context); if (actual === undefined || actual === null) return false; const actualStr = String(actual); @@ -158,10 +170,6 @@ function evaluateCondition( return Number(actual) > Number(expected); case "lessThan": return Number(actual) < Number(expected); - case "hasRole": - return context.member?.roles.cache.has(expected) ?? false; - case "notHasRole": - return !(context.member?.roles.cache.has(expected) ?? true); case "inList": return expected .split(",") @@ -209,12 +217,12 @@ async function executeSteps( try { const executor = getExecutor(step.action.type as ActionType); if (!executor) { - logger.warn( - `Unknown action type: ${step.action.type} in rule "${rule.name}"`, - ); - } else { - await executor(client, context, step.action); + // Must not fall through to the success log below: a rule + // referencing a removed action type would report a clean 100% + // success rate while doing nothing at all. + throw new Error(`Unknown action type: ${step.action.type}`); } + await executor(client, context, step.action); logExecution(rule, step.action.type, true, null).catch(() => {}); } catch (error) { const err = diff --git a/apps/bot/src/features/automation/system/registry.ts b/apps/bot/src/features/automation/system/registry.ts index bbd63691..92708e92 100644 --- a/apps/bot/src/features/automation/system/registry.ts +++ b/apps/bot/src/features/automation/system/registry.ts @@ -32,13 +32,60 @@ function isValidEmoji(value: string): boolean { return UNICODE_EMOJI_REGEX.test(value); } +/** + * Every executor THROWS rather than returning when it cannot do its job. + * + * processEvent cannot distinguish "returned because there was nothing to do" + * from "succeeded", so a silent return was written to the ActionLog as + * `success: true`. A moderator whose auto-role rule never fired saw + * "214 executions, 100% success" and had nothing to debug with. Throwing lets + * the caller's existing catch record the real reason. + */ +function required(value: T | undefined | null, actionType: string, field: string): T { + if (value === undefined || value === null || value === "") { + throw new Error(`${actionType}: ${field} is required`); + } + return value; +} + +/** Fetches a sendable text channel, or throws explaining which one failed. */ +async function sendableChannel(client: Client, channelId: string, actionType: string) { + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased() || !("send" in channel)) { + throw new Error( + `${actionType}: channel ${channelId} is missing, not a text channel, or not visible to the bot`, + ); + } + return channel; +} + +/** + * Resolves the member to act on. Reaction, ban and message contexts often + * carry no member object, which is why addRole/removeRole silently did nothing + * on the single most common automation people build ("react here, get a role"). + */ +async function resolveMember(client: Client, ctx: EventContext, actionType: string) { + if (ctx.member) return ctx.member; + if (!ctx.userId || !ctx.guildId) { + throw new Error(`${actionType}: no member in context and no user/guild to resolve one from`); + } + try { + const guild = await client.guilds.fetch(ctx.guildId); + return await guild.members.fetch(ctx.userId); + } catch (err) { + throw new Error( + `${actionType}: could not resolve member ${ctx.userId} in guild ${ctx.guildId} (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + const executors = new Map(); executors.set("sendMessage", async (client, ctx, config) => { - if (!config.channelId || !config.message) return; - const channel = await client.channels.fetch(config.channelId); - if (!channel?.isTextBased() || !("send" in channel)) return; - const resolved = resolveTemplate(config.message, ctx); + const channelId = required(config.channelId, "sendMessage", "channelId"); + const message = required(config.message, "sendMessage", "message"); + const channel = await sendableChannel(client, channelId, "sendMessage"); + const resolved = resolveTemplate(message, ctx); await channel.send({ content: resolved, allowedMentions: { users: ctx.userId ? [ctx.userId] : [] }, @@ -46,56 +93,55 @@ executors.set("sendMessage", async (client, ctx, config) => { }); executors.set("sendEmbed", async (client, ctx, config) => { - if (!config.channelId || !config.embed) return; - const channel = await client.channels.fetch(config.channelId); - if (!channel?.isTextBased() || !("send" in channel)) return; + const channelId = required(config.channelId, "sendEmbed", "channelId"); + const embedConfig = required(config.embed, "sendEmbed", "embed"); + const channel = await sendableChannel(client, channelId, "sendEmbed"); const embed = new EmbedBuilder().setTimestamp(); - if (config.embed.title) { - embed.setTitle(resolveTemplate(config.embed.title, ctx)); + if (embedConfig.title) { + embed.setTitle(resolveTemplate(embedConfig.title, ctx)); } - if (config.embed.description) { - embed.setDescription(resolveTemplate(config.embed.description, ctx)); + if (embedConfig.description) { + embed.setDescription(resolveTemplate(embedConfig.description, ctx)); } - if (config.embed.color !== undefined) { - embed.setColor(config.embed.color); + if (embedConfig.color !== undefined) { + embed.setColor(embedConfig.color); } - if (config.embed.footer) { - embed.setFooter({ text: resolveTemplate(config.embed.footer, ctx) }); + if (embedConfig.footer) { + embed.setFooter({ text: resolveTemplate(embedConfig.footer, ctx) }); } await channel.send({ embeds: [embed] }); }); executors.set("sendDM", async (client, ctx, config) => { - if (!config.message) return; - const resolved = resolveTemplate(config.message, ctx); - try { - // Prefer ctx.member.user if available, otherwise fetch the user directly - if (ctx.member) { - await ctx.member.user.send(resolved); - } else if (ctx.userId) { - const user = await client.users.fetch(ctx.userId); - await user.send(resolved); - } - } catch (err) { - logger.warn(`sendDM failed for user ${ctx.userId ?? "unknown"}: ${err instanceof Error ? err.message : String(err)}`); + const message = required(config.message, "sendDM", "message"); + const resolved = resolveTemplate(message, ctx); + // A closed DM is a real, reportable failure — the moderator needs to know + // their welcome DM is not arriving, not see it logged as a success. + if (ctx.member) { + await ctx.member.user.send(resolved); + return; } + const userId = required(ctx.userId, "sendDM", "a user to DM"); + const user = await client.users.fetch(userId); + await user.send(resolved); }); -executors.set("addRole", async (_client, ctx, config) => { - if (!ctx.member || !config.roleId) return; - await ctx.member.roles.add(config.roleId); +executors.set("addRole", async (client, ctx, config) => { + const roleId = required(config.roleId, "addRole", "roleId"); + const member = await resolveMember(client, ctx, "addRole"); + await member.roles.add(roleId); }); -executors.set("removeRole", async (_client, ctx, config) => { - if (!ctx.member || !config.roleId) return; - await ctx.member.roles.remove(config.roleId); +executors.set("removeRole", async (client, ctx, config) => { + const roleId = required(config.roleId, "removeRole", "roleId"); + const member = await resolveMember(client, ctx, "removeRole"); + await member.roles.remove(roleId); }); executors.set("logToChannel", async (client, ctx, config) => { - if (!config.channelId) return; - const channel = await client.channels.fetch(config.channelId); - if (!channel?.isTextBased() || !("send" in channel)) return; + const channelId = required(config.channelId, "logToChannel", "channelId"); + const channel = await sendableChannel(client, channelId, "logToChannel"); const eventInfo = EVENT_TYPES[ctx.eventType]; const embed = new EmbedBuilder() @@ -155,28 +201,60 @@ async function isPrivateHost(hostname: string): Promise { } } -executors.set("sendWebhook", async (_client, ctx, config) => { - if (!config.webhook?.url) return; +const MAX_WEBHOOK_REDIRECTS = 3; +const MAX_WEBHOOK_RESPONSE_BYTES = 64 * 1024; +/** Throws unless the URL is https and does not resolve to a private address. */ +async function assertSafeWebhookTarget(raw: string): Promise { let url: URL; try { - url = new URL(config.webhook.url); + url = new URL(raw); } catch { - logger.warn(`Invalid webhook URL in action config: ${config.webhook.url}`); - return; + throw new Error(`sendWebhook: "${raw}" is not a valid URL`); } - if (url.protocol !== "https:") { - logger.warn(`Webhook URL must use HTTPS: ${config.webhook.url}`); - return; + throw new Error(`sendWebhook: URL must use HTTPS, got ${url.protocol.replace(":", "")}`); } - if (await isPrivateHost(url.hostname)) { - logger.warn(`Webhook URL points to private/internal host: ${url.hostname}`); + throw new Error(`sendWebhook: ${url.hostname} resolves to a private/internal address`); + } + return url; +} + +/** + * Reads at most `MAX_WEBHOOK_RESPONSE_BYTES` and then cancels the stream. + * `await response.text()` buffers whatever the remote chooses to send, so a + * hostile or broken endpoint could stream until the bot ran out of memory. We + * only read at all to release the connection; the content is never used. + */ +async function drainCapped(response: { body?: unknown; text?: () => Promise }): Promise { + const body = response.body as + | { getReader(): { read(): Promise<{ done: boolean; value?: { length: number } }>; cancel(): unknown } } + | null + | undefined; + if (!body?.getReader) { + await response.text?.(); return; } + const reader = body.getReader(); + let read = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + read += value?.length ?? 0; + if (read >= MAX_WEBHOOK_RESPONSE_BYTES) break; + } + } finally { + await reader.cancel(); + } +} + +executors.set("sendWebhook", async (_client, ctx, config) => { + const rawUrl = required(config.webhook?.url, "sendWebhook", "webhook.url"); + let url = await assertSafeWebhookTarget(rawUrl); - let body = config.webhook.bodyTemplate ?? JSON.stringify({ + let body = config.webhook?.bodyTemplate ?? JSON.stringify({ event: ctx.eventType, guild: ctx.guildName, user: ctx.userName, @@ -203,7 +281,7 @@ executors.set("sendWebhook", async (_client, ctx, config) => { ]); const ALLOWED_PREFIX = "x-fluxcore-"; - const userHeaders = config.webhook.headers ?? {}; + const userHeaders = config.webhook?.headers ?? {}; const headers: Record = { "Content-Type": "application/json", }; @@ -219,49 +297,69 @@ executors.set("sendWebhook", async (_client, ctx, config) => { } } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); + // Redirects are followed BY HAND so every hop is re-checked against the + // private-address rules. With fetch's automatic following, the SSRF guard ran + // once against the original hostname and a public host answering + // "302 -> http://169.254.169.254/" walked straight into the cloud metadata + // service. + for (let hop = 0; ; hop++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + let response: Response; + try { + response = await fetch(url.toString(), { + method: config.webhook?.method ?? "POST", + headers, + body, + signal: controller.signal, + redirect: "manual", + }); + } finally { + clearTimeout(timeout); + } - try { - const response = await fetch(config.webhook.url, { - method: config.webhook.method ?? "POST", - headers, - body, - signal: controller.signal, - }); - // Consume response body to release the connection - await response.text(); - } finally { - clearTimeout(timeout); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + await drainCapped(response); + if (!location) { + throw new Error(`sendWebhook: ${response.status} redirect with no Location header`); + } + if (hop >= MAX_WEBHOOK_REDIRECTS) { + throw new Error(`sendWebhook: too many redirects (>${MAX_WEBHOOK_REDIRECTS})`); + } + url = await assertSafeWebhookTarget(new URL(location, url).toString()); + continue; + } + + await drainCapped(response); + if (response.status < 200 || response.status >= 300) { + throw new Error(`sendWebhook: ${url.hostname} responded ${response.status}`); + } + return; } }); // --- Set Nickname --- executors.set("setNickname", async (client, ctx, config) => { - if (!config.nickname) return; - try { - // Fetch member from guild if not in context - let member = ctx.member; - if (!member && ctx.userId && ctx.guildId) { - const guild = await client.guilds.fetch(ctx.guildId); - member = await guild.members.fetch(ctx.userId); - } - if (!member) return; - const resolved = resolveTemplate(config.nickname, ctx); - await member.setNickname(resolved.slice(0, 32)); - } catch (err) { - logger.warn(`setNickname failed for user ${ctx.userId ?? "unknown"}: ${err instanceof Error ? err.message : String(err)}`); - } + const nickname = required(config.nickname, "setNickname", "nickname"); + const member = await resolveMember(client, ctx, "setNickname"); + const resolved = resolveTemplate(nickname, ctx); + await member.setNickname(resolved.slice(0, 32)); }); // --- Create Thread --- executors.set("createThread", async (client, ctx, config) => { - if (!config.channelId || !config.threadName) return; - const channel = await client.channels.fetch(config.channelId); - if (!channel?.isTextBased() || !("threads" in channel)) return; - const resolved = resolveTemplate(config.threadName, ctx); + const channelId = required(config.channelId, "createThread", "channelId"); + const threadName = required(config.threadName, "createThread", "threadName"); + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased() || !("threads" in channel)) { + throw new Error( + `createThread: channel ${channelId} is missing, not visible to the bot, or does not support threads`, + ); + } + const resolved = resolveTemplate(threadName, ctx); await (channel as TextChannel).threads.create({ name: resolved.slice(0, 100), }); @@ -270,23 +368,18 @@ executors.set("createThread", async (client, ctx, config) => { // --- Add Reaction --- executors.set("addReaction", async (client, ctx, config) => { - if (!config.emoji || !ctx.extra?.["message.id"] || !ctx.channelId) return; - if (!isValidEmoji(config.emoji)) { - logger.warn( - `addReaction skipped: invalid emoji "${config.emoji}" for guild ${ctx.guildId ?? "unknown"}`, - ); - return; + const emoji = required(config.emoji, "addReaction", "emoji"); + if (!isValidEmoji(emoji)) { + throw new Error(`addReaction: "${emoji}" is not a valid unicode or custom emoji`); } - const channel = await client.channels.fetch(ctx.channelId); - if (!channel?.isTextBased() || !("messages" in channel)) return; - try { - const message = await (channel as TextChannel).messages.fetch( - ctx.extra["message.id"], - ); - await message.react(config.emoji); - } catch (err) { - logger.warn(`addReaction failed (emoji: ${config.emoji}, message: ${ctx.extra?.["message.id"] ?? "unknown"}): ${err instanceof Error ? err.message : String(err)}`); + const messageId = required(ctx.extra?.["message.id"], "addReaction", "a triggering message"); + const channelId = required(ctx.channelId, "addReaction", "a channel in the event context"); + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased() || !("messages" in channel)) { + throw new Error(`addReaction: channel ${channelId} is missing or not visible to the bot`); } + const message = await (channel as TextChannel).messages.fetch(messageId); + await message.react(emoji); }); export function getExecutor(actionType: ActionType): ActionExecutor | undefined { diff --git a/apps/bot/tests/features/automation/system/executor.test.ts b/apps/bot/tests/features/automation/system/executor.test.ts index 27875129..b5ad4cef 100644 --- a/apps/bot/tests/features/automation/system/executor.test.ts +++ b/apps/bot/tests/features/automation/system/executor.test.ts @@ -330,3 +330,137 @@ describe("action executor - filters fail closed", () => { expect(mockSendMessageExecutor).toHaveBeenCalled(); }); }); + +describe("action executor - the log tells the truth", () => { + const ctx = { + eventType: "memberJoin" as const, + guildId: "guild-123", + guildName: "Test Guild", + userId: "user-456", + memberCount: 100, + timestamp: new Date().toISOString(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetGuildSettingsOrDefault.mockReturnValue({ globalEnabled: true, maxRules: 25 }); + }); + + // The step-mode branch warned about an unknown action type and then fell + // through to the success log, so a rule referencing a removed action type + // reported a clean 100% success rate forever. + it("does not log success for an unknown action type in step mode", async () => { + mockGetRulesForEvent.mockReturnValueOnce([ + { + name: "unknown-step", + enabled: true, + conditions: {}, + actions: [], + entryStepId: "s0", + steps: [{ id: "s0", type: "action", action: { type: "unknownAction" }, next: null }], + }, + ]); + + await processEvent({} as never, ctx); + + expect(mockLogExecution).not.toHaveBeenCalledWith( + expect.anything(), expect.anything(), true, expect.anything(), + ); + }); + + it("logs a failure for an unknown action type in step mode", async () => { + mockGetRulesForEvent.mockReturnValueOnce([ + { + name: "unknown-step", + enabled: true, + conditions: {}, + actions: [], + entryStepId: "s0", + steps: [{ id: "s0", type: "action", action: { type: "unknownAction" }, next: null }], + }, + ]); + + await processEvent({} as never, ctx); + + expect(mockLogExecution).toHaveBeenCalledWith( + expect.anything(), "unknownAction", false, expect.stringMatching(/unknown action type/i), + ); + }); +}); + +/** + * Condition steps read `getContextValue(condition.field)` and bail when it is + * undefined — BEFORE looking at the operator. hasRole/notHasRole do not consume + * the field at all, so the canonical "if member has @Verified" branch always + * took the else path on memberJoin (whose context has no channelId, the + * default field). + */ +describe("action executor - role operators ignore the unrelated field", () => { + const memberWithRole = { + eventType: "memberJoin" as const, + guildId: "guild-123", + guildName: "Test Guild", + userId: "user-456", + memberCount: 100, + timestamp: new Date().toISOString(), + member: { roles: { cache: new Map([["verified", {}]]) } }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetGuildSettingsOrDefault.mockReturnValue({ globalEnabled: true, maxRules: 25 }); + }); + + function branchRule(operator: string) { + return [ + { + name: "branch", + enabled: true, + conditions: {}, + actions: [], + entryStepId: "cond", + steps: [ + { + id: "cond", + type: "condition", + // channelId is the default field and memberJoin never populates it. + condition: { field: "channelId", operator, value: "verified" }, + thenNext: "yes", + elseNext: null, + }, + { id: "yes", type: "action", action: { type: "sendMessage" }, next: null }, + ], + }, + ]; + } + + it("takes the then-branch when the member has the role", async () => { + mockGetRulesForEvent.mockReturnValueOnce(branchRule("hasRole")); + + await processEvent({} as never, memberWithRole as never); + + expect(mockSendMessageExecutor).toHaveBeenCalled(); + }); + + it("takes the else-branch when the member lacks the role", async () => { + mockGetRulesForEvent.mockReturnValueOnce(branchRule("hasRole")); + + await processEvent({} as never, { + ...memberWithRole, + member: { roles: { cache: new Map() } }, + } as never); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); + + it("notHasRole takes the then-branch when the member lacks the role", async () => { + mockGetRulesForEvent.mockReturnValueOnce(branchRule("notHasRole")); + + await processEvent({} as never, { + ...memberWithRole, + member: { roles: { cache: new Map() } }, + } as never); + + expect(mockSendMessageExecutor).toHaveBeenCalled(); + }); +}); diff --git a/apps/bot/tests/features/automation/system/registry-emoji.test.ts b/apps/bot/tests/features/automation/system/registry-emoji.test.ts index ff4fe285..5cc6ad93 100644 --- a/apps/bot/tests/features/automation/system/registry-emoji.test.ts +++ b/apps/bot/tests/features/automation/system/registry-emoji.test.ts @@ -54,15 +54,17 @@ describe("addReaction emoji validation", () => { const client = mkClient(fetchSpy); const executor = getExecutor("addReaction")!; - await executor(client, baseCtx as never, { - type: "addReaction", - emoji: "not-an-emoji-at-all", - } as never); + // A bad emoji is now a thrown failure rather than a warn-and-return, so + // processEvent records it as `success: false` instead of logging a + // successful execution for an action that never ran. + await expect( + executor(client, baseCtx as never, { + type: "addReaction", + emoji: "not-an-emoji-at-all", + } as never), + ).rejects.toThrow(/not a valid unicode or custom emoji/); expect(fetchSpy).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("invalid emoji"), - ); }); it("accepts a unicode emoji", async () => { @@ -102,10 +104,12 @@ describe("addReaction emoji validation", () => { const fetchSpy = vi.fn(); const client = mkClient(fetchSpy); const executor = getExecutor("addReaction")!; - await executor(client, baseCtx as never, { - type: "addReaction", - emoji: ":smile:", - } as never); + await expect( + executor(client, baseCtx as never, { + type: "addReaction", + emoji: ":smile:", + } as never), + ).rejects.toThrow(/not a valid unicode or custom emoji/); expect(fetchSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/bot/tests/features/automation/system/registry-failures.test.ts b/apps/bot/tests/features/automation/system/registry-failures.test.ts new file mode 100644 index 00000000..2336a10b --- /dev/null +++ b/apps/bot/tests/features/automation/system/registry-failures.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock("node:dns/promises", () => ({ + lookup: vi.fn().mockResolvedValue({ address: "1.1.1.1", family: 4 }), +})); + +const { getExecutor } = await import( + "../../../../src/features/automation/system/registry.js" +); + +const baseCtx = { + eventType: "messageCreated" as const, + guildId: "g1", + userId: "u1", + userName: "alice", + userTag: "alice#0001", + userMention: "<@u1>", + channelId: "c1", + guildName: "G", + memberCount: 10, + timestamp: new Date().toISOString(), +}; + +/** + * Every executor used to `return` when it could not do its job — no channel + * configured, no member to act on, an unresolvable role. processEvent has no + * way to tell that apart from success, so it wrote `success: true` to the + * ActionLog. A moderator whose auto-role rule never fired saw "214 executions, + * 100% success" and had nothing to debug with. + * + * Every case below must reject, so the caller's existing catch logs it as a + * failure with a readable reason. + */ +describe("action executors fail loudly rather than silently doing nothing", () => { + function clientWithNoChannel() { + return { channels: { fetch: vi.fn().mockResolvedValue(null) } } as never; + } + + it("sendMessage rejects when no channel is configured", async () => { + const run = getExecutor("sendMessage")!; + await expect(run(clientWithNoChannel(), baseCtx, { type: "sendMessage", message: "hi" })) + .rejects.toThrow(/channelId/i); + }); + + it("sendMessage rejects when no message is configured", async () => { + const run = getExecutor("sendMessage")!; + await expect(run(clientWithNoChannel(), baseCtx, { type: "sendMessage", channelId: "c1" })) + .rejects.toThrow(/message/i); + }); + + it("sendMessage rejects when the channel cannot be resolved", async () => { + const run = getExecutor("sendMessage")!; + await expect( + run(clientWithNoChannel(), baseCtx, { type: "sendMessage", channelId: "gone", message: "hi" }), + ).rejects.toThrow(/channel/i); + }); + + it("addRole rejects when no role is configured", async () => { + const run = getExecutor("addRole")!; + await expect(run({} as never, baseCtx, { type: "addRole" })).rejects.toThrow(/roleId/i); + }); + + // The canonical reaction-role automation: "react to this message, get a + // role". A reaction context carries no member, so this silently did nothing + // and reported success for every reaction. + it("addRole resolves the member from the guild when the context has none", async () => { + const add = vi.fn().mockResolvedValue(undefined); + const client = { + guilds: { + fetch: vi.fn().mockResolvedValue({ + members: { fetch: vi.fn().mockResolvedValue({ roles: { add, remove: vi.fn() } }) }, + }), + }, + } as never; + + await getExecutor("addRole")!(client, baseCtx, { type: "addRole", roleId: "r1" }); + + expect(add).toHaveBeenCalledWith("r1"); + }); + + it("addRole rejects when the member cannot be resolved", async () => { + const client = { + guilds: { + fetch: vi.fn().mockResolvedValue({ + members: { fetch: vi.fn().mockRejectedValue(new Error("Unknown Member")) }, + }), + }, + } as never; + + await expect( + getExecutor("addRole")!(client, baseCtx, { type: "addRole", roleId: "r1" }), + ).rejects.toThrow(/member/i); + }); + + it("removeRole resolves the member from the guild when the context has none", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + const client = { + guilds: { + fetch: vi.fn().mockResolvedValue({ + members: { fetch: vi.fn().mockResolvedValue({ roles: { add: vi.fn(), remove } }) }, + }), + }, + } as never; + + await getExecutor("removeRole")!(client, baseCtx, { type: "removeRole", roleId: "r1" }); + + expect(remove).toHaveBeenCalledWith("r1"); + }); + + it("sendDM rejects when the DM cannot be delivered", async () => { + const client = { + users: { fetch: vi.fn().mockResolvedValue({ send: vi.fn().mockRejectedValue(new Error("Cannot send messages to this user")) }) }, + } as never; + + await expect( + getExecutor("sendDM")!(client, baseCtx, { type: "sendDM", message: "hi" }), + ).rejects.toThrow(/cannot send/i); + }); + + it("setNickname rejects when Discord refuses the change", async () => { + const client = { + guilds: { + fetch: vi.fn().mockResolvedValue({ + members: { + fetch: vi.fn().mockResolvedValue({ + setNickname: vi.fn().mockRejectedValue(new Error("Missing Permissions")), + }), + }, + }), + }, + } as never; + + await expect( + getExecutor("setNickname")!(client, baseCtx, { type: "setNickname", nickname: "n" }), + ).rejects.toThrow(/missing permissions/i); + }); + + it("addReaction rejects on an invalid emoji instead of warning and returning", async () => { + const client = { channels: { fetch: vi.fn() } } as never; + + await expect( + getExecutor("addReaction")!( + client, + { ...baseCtx, extra: { "message.id": "m1" } }, + { type: "addReaction", emoji: "not an emoji" }, + ), + ).rejects.toThrow(/emoji/i); + }); + + it("logToChannel rejects when the channel cannot be resolved", async () => { + const run = getExecutor("logToChannel")!; + await expect(run(clientWithNoChannel(), baseCtx, { type: "logToChannel", channelId: "gone" })) + .rejects.toThrow(/channel/i); + }); + + it("createThread rejects when the channel cannot be resolved", async () => { + const run = getExecutor("createThread")!; + await expect( + run(clientWithNoChannel(), baseCtx, { type: "createThread", channelId: "gone", threadName: "t" }), + ).rejects.toThrow(/channel/i); + }); +}); diff --git a/apps/bot/tests/features/automation/system/registry-webhook-safety.test.ts b/apps/bot/tests/features/automation/system/registry-webhook-safety.test.ts new file mode 100644 index 00000000..ef144c2b --- /dev/null +++ b/apps/bot/tests/features/automation/system/registry-webhook-safety.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +const lookup = vi.hoisted(() => vi.fn()); +vi.mock("node:dns/promises", () => ({ lookup })); + +const { getExecutor } = await import( + "../../../../src/features/automation/system/registry.js" +); + +const ctx = { + eventType: "memberJoin" as const, + guildId: "g1", + guildName: "G", + userId: "u1", + userName: "alice", + memberCount: 1, + timestamp: new Date().toISOString(), +}; + +function cfg(url: string) { + return { type: "sendWebhook", webhook: { url } }; +} + +/** A Response-alike carrying a body of `size` bytes, streamed in 1 KiB chunks. */ +function streamingResponse(size: number, status = 200) { + let sent = 0; + return { + status, + ok: status >= 200 && status < 300, + headers: { get: () => null }, + body: { + getReader() { + return { + async read() { + if (sent >= size) return { done: true, value: undefined }; + const chunk = Math.min(1024, size - sent); + sent += chunk; + return { done: false, value: new Uint8Array(chunk) }; + }, + cancel: vi.fn(), + releaseLock() {}, + }; + }, + }, + text: async () => "x".repeat(size), + }; +} + +describe("sendWebhook safety", () => { + beforeEach(() => { + vi.restoreAllMocks(); + lookup.mockResolvedValue({ address: "1.1.1.1", family: 4 }); + }); + + it("rejects a missing URL rather than returning silently", async () => { + await expect( + getExecutor("sendWebhook")!({} as never, ctx, { type: "sendWebhook" }), + ).rejects.toThrow(/url/i); + }); + + it("rejects plain HTTP", async () => { + await expect( + getExecutor("sendWebhook")!({} as never, ctx, cfg("http://example.com/h")), + ).rejects.toThrow(/https/i); + }); + + it("rejects a private host", async () => { + lookup.mockResolvedValue({ address: "10.0.0.5", family: 4 }); + await expect( + getExecutor("sendWebhook")!({} as never, ctx, cfg("https://internal.example/h")), + ).rejects.toThrow(/private|internal/i); + }); + + // The private-IP check ran once, against the ORIGINAL hostname. A public + // host answering 302 -> http://169.254.169.254/ walked straight past it into + // the cloud metadata service. + it("re-validates the target of a redirect", async () => { + lookup.mockImplementation(async (host: string) => + host === "evil.example" ? { address: "1.1.1.1", family: 4 } : { address: "169.254.169.254", family: 4 }, + ); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 302, + ok: false, + headers: { get: (h: string) => (h.toLowerCase() === "location" ? "https://metadata.internal/latest" : null) }, + body: null, + text: async () => "", + }), + ); + + await expect( + getExecutor("sendWebhook")!({} as never, ctx, cfg("https://evil.example/h")), + ).rejects.toThrow(/private|internal|redirect/i); + }); + + it("does not follow redirects endlessly", async () => { + const f = vi.fn().mockResolvedValue({ + status: 302, + ok: false, + headers: { get: (h: string) => (h.toLowerCase() === "location" ? "https://example.com/loop" : null) }, + body: null, + text: async () => "", + }); + vi.stubGlobal("fetch", f); + + await expect( + getExecutor("sendWebhook")!({} as never, ctx, cfg("https://example.com/h")), + ).rejects.toThrow(/redirect/i); + expect(f.mock.calls.length).toBeLessThanOrEqual(4); + }); + + it("rejects a non-2xx response so the failure is logged", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(streamingResponse(10, 500))); + + await expect( + getExecutor("sendWebhook")!({} as never, ctx, cfg("https://example.com/h")), + ).rejects.toThrow(/500/); + }); + + it("stops reading a response body that exceeds the cap", async () => { + const reader = { calls: 0 }; + const oversize = streamingResponse(5_000_000); + const originalGetReader = oversize.body!.getReader.bind(oversize.body); + oversize.body!.getReader = () => { + reader.calls++; + return originalGetReader(); + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(oversize)); + + // Must complete rather than buffering 5 MB into memory. + await getExecutor("sendWebhook")!({} as never, ctx, cfg("https://example.com/h")); + expect(reader.calls).toBe(1); + }); + + it("sends successfully on a 2xx", async () => { + const f = vi.fn().mockResolvedValue(streamingResponse(10)); + vi.stubGlobal("fetch", f); + + await getExecutor("sendWebhook")!({} as never, ctx, cfg("https://example.com/h")); + + expect(f).toHaveBeenCalledOnce(); + }); +}); From 790b4d24959caaae1ef0c9c5188ec930e45e03ad Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 22:30:56 +0300 Subject: [PATCH 09/19] fix(automation): make reaction, thread and announcement triggers actually fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate reasons an automation could never run at all. **No Partials.** ExtendedClient enabled none, so discord.js silently DROPPED any gateway event whose primary structure was not already cached. The bot only caches messages seen since its last restart, so reactionAdded / reactionRemoved / messageDeleted never reached a handler for an older message — which breaks every reaction-role automation attached to a pinned rules message, with no error anywhere. (The standalone messageReactionAdd handler already fetched partials; that code had simply never been reachable.) The event bridge now resolves the partial reaction and user before building a context, and treats an unfetchable deleted message as a drop rather than an error. **threadCreated could not be scoped.** context.channelId is the brand-new thread, an id no user could have picked, so a channel filter never matched. EventContext carries parentChannelId now and channel filters test both. **Announcement/stage/forum channels were invisible.** Three components each hardcoded `c.type === 0 || c.type === 2`, so a rule could not be scoped to an announcement channel and Send Message could not target one. Replaced with one shared `channelTypes.ts` (isMessageableChannel + per-type icons), used by DiscordMultiSelect and ActionFields. +13 tests. Dashboard client 470/470, bot 409 passing (5 pre-existing guildMemberAdd failures), all typechecks clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/automation/system/eventBridge.ts | 51 ++++-- .../features/automation/system/executor.ts | 15 +- apps/bot/src/shared/client/ExtendedClient.ts | 14 +- .../system/eventBridge-partials.test.ts | 159 ++++++++++++++++++ .../automation/system/executor.test.ts | 37 ++++ .../shared/client/ExtendedClient.test.ts | 19 ++- .../automation/components/ActionFields.tsx | 5 +- .../src/client/shared/lib/channelTypes.ts | 84 +++++++++ .../client/shared/ui/discord-multi-select.tsx | 12 +- .../client/shared/lib/channelTypes.test.ts | 52 ++++++ packages/systems/src/actions/types.ts | 8 + 11 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 apps/bot/tests/features/automation/system/eventBridge-partials.test.ts create mode 100644 apps/dashboard/src/client/shared/lib/channelTypes.ts create mode 100644 apps/dashboard/tests/client/shared/lib/channelTypes.test.ts diff --git a/apps/bot/src/features/automation/system/eventBridge.ts b/apps/bot/src/features/automation/system/eventBridge.ts index b05cf647..af33491e 100644 --- a/apps/bot/src/features/automation/system/eventBridge.ts +++ b/apps/bot/src/features/automation/system/eventBridge.ts @@ -211,7 +211,7 @@ export function registerActionEventListeners(client: Client): void { // Message created client.on("messageCreate", (message) => { - if (!message.guildId || message.author.bot) return; + if (!message.guildId || message.author?.bot) return; if (getRulesForEvent(message.guildId, "messageCreated").length === 0) return; const ctx = buildMessageContext("messageCreated", message); processEvent(client, ctx).catch((e) => @@ -229,24 +229,35 @@ export function registerActionEventListeners(client: Client): void { ); }); - // Reaction added/removed - client.on("messageReactionAdd", (reaction, user) => { - if (!reaction.message.guildId || user.bot) return; - if (getRulesForEvent(reaction.message.guildId, "reactionAdded").length === 0) return; - const ctx = buildReactionContext("reactionAdded", reaction, user); - processEvent(client, ctx).catch((e) => - handleError("reactionAdded", e), - ); - }); + // Reaction added/removed. + // + // Partials are enabled on the client, so these fire for messages the bot has + // never cached — which is the normal case for a pinned rules message. Both + // the reaction and the user must be resolved before the context is built, or + // every reaction automation on an older message stays silently broken. + const onReaction = ( + eventType: "reactionAdded" | "reactionRemoved", + ) => async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot) return; + if (reaction.partial) await reaction.fetch(); + if (user.partial) user = await user.fetch(); + const guildId = reaction.message.guildId; + if (!guildId) return; + if (getRulesForEvent(guildId, eventType).length === 0) return; + await processEvent(client, buildReactionContext(eventType, reaction, user)); + } catch (e) { + // A deleted message cannot be fetched back; that is not an error worth + // surfacing, but anything else is. + handleError(eventType, e); + } + }; - client.on("messageReactionRemove", (reaction, user) => { - if (!reaction.message.guildId || user.bot) return; - if (getRulesForEvent(reaction.message.guildId, "reactionRemoved").length === 0) return; - const ctx = buildReactionContext("reactionRemoved", reaction, user); - processEvent(client, ctx).catch((e) => - handleError("reactionRemoved", e), - ); - }); + client.on("messageReactionAdd", onReaction("reactionAdded")); + client.on("messageReactionRemove", onReaction("reactionRemoved")); // Role added/removed + nickname/timeout/boost (all via guildMemberUpdate) client.on("guildMemberUpdate", (oldMember, newMember) => { @@ -379,6 +390,10 @@ export function registerActionEventListeners(client: Client): void { channelId: thread.id, channelName: thread.name, channelMention: `<#${thread.id}>`, + // The thread IS the context channel, so a channel filter would never + // match anything a user could pick. Carry the parent so + // "only threads under #support" is expressible. + parentChannelId: thread.parentId ?? undefined, memberCount: thread.guild.memberCount, timestamp: new Date().toISOString(), extra: { diff --git a/apps/bot/src/features/automation/system/executor.ts b/apps/bot/src/features/automation/system/executor.ts index 038fd521..f81ac029 100644 --- a/apps/bot/src/features/automation/system/executor.ts +++ b/apps/bot/src/features/automation/system/executor.ts @@ -65,10 +65,17 @@ function matchesConditions( conditions: ActionConditions, context: EventContext, ): boolean { + // Channel filters test the parent too. On threadCreated the context channel + // is the brand-new thread, whose id the user cannot possibly have picked in + // the editor; the parent is the channel they actually chose. + const channelIds = [context.channelId, context.parentChannelId].filter( + (id): id is string => !!id, + ); + // Include filters: the context must carry the datum AND match it. if (conditions.channelIds?.length) { - if (!context.channelId) return false; - if (!conditions.channelIds.includes(context.channelId)) return false; + if (channelIds.length === 0) return false; + if (!channelIds.some((id) => conditions.channelIds!.includes(id))) return false; } if (conditions.userIds?.length) { if (!context.userId) return false; @@ -84,8 +91,8 @@ function matchesConditions( // Exclude filters: an unanswerable exclusion cannot be cleared, so it blocks. if (conditions.excludeChannelIds?.length) { - if (!context.channelId) return false; - if (conditions.excludeChannelIds.includes(context.channelId)) return false; + if (channelIds.length === 0) return false; + if (channelIds.some((id) => conditions.excludeChannelIds!.includes(id))) return false; } if (conditions.excludeUserIds?.length) { if (!context.userId) return false; diff --git a/apps/bot/src/shared/client/ExtendedClient.ts b/apps/bot/src/shared/client/ExtendedClient.ts index a8e4ffaf..313a3ae0 100644 --- a/apps/bot/src/shared/client/ExtendedClient.ts +++ b/apps/bot/src/shared/client/ExtendedClient.ts @@ -1,4 +1,4 @@ -import { Client, Collection, GatewayIntentBits } from "discord.js"; +import { Client, Collection, GatewayIntentBits, Partials } from "discord.js"; import type { Command } from "@fluxcore/types"; export class ExtendedClient extends Client { @@ -15,6 +15,18 @@ export class ExtendedClient extends Client { GatewayIntentBits.MessageContent, // Privileged: required for message content access GatewayIntentBits.GuildMessageReactions, // Required for reaction events ], + // Without these, discord.js silently DROPS any event whose primary + // structure is not already in cache. The bot only caches messages it has + // seen since its last restart, so reactionAdded / reactionRemoved / + // messageDeleted never fired for an older message — which breaks every + // reaction-role automation attached to a pinned rules message, with no + // error anywhere. Handlers that read a partial must fetch it first. + partials: [ + Partials.Message, + Partials.Reaction, + Partials.Channel, + Partials.User, + ], }); } } diff --git a/apps/bot/tests/features/automation/system/eventBridge-partials.test.ts b/apps/bot/tests/features/automation/system/eventBridge-partials.test.ts new file mode 100644 index 00000000..6b378ba5 --- /dev/null +++ b/apps/bot/tests/features/automation/system/eventBridge-partials.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +const mockProcessEvent = vi.fn().mockResolvedValue(undefined); +vi.mock("../../../../src/features/automation/system/executor.js", () => ({ + processEvent: (...args: unknown[]) => mockProcessEvent(...args), +})); + +const mockGetRulesForEvent = vi.fn().mockReturnValue([{ id: 1 }]); +vi.mock("@fluxcore/systems/actions/cache", () => ({ + getRulesForEvent: (...args: unknown[]) => mockGetRulesForEvent(...args), +})); + +const { registerActionEventListeners } = await import( + "../../../../src/features/automation/system/eventBridge.js" +); + +/** Captures the handlers registered via client.on so tests can invoke them. */ +function fakeClient() { + const handlers = new Map unknown>(); + return { + client: { on: (name: string, fn: (...args: unknown[]) => unknown) => handlers.set(name, fn) }, + handlers, + }; +} + +function partialReaction(overrides: Record = {}) { + return { + partial: true, + fetch: vi.fn().mockResolvedValue(undefined), + emoji: { toString: () => "🎉", name: "tada" }, + message: { + guildId: "g1", + guild: { name: "G", memberCount: 5 }, + channelId: "c1", + channel: { name: "general" }, + id: "m1", + url: "https://discord.com/x", + }, + ...overrides, + }; +} + +/** + * Partials are now enabled on the client, so reaction events arrive for + * messages the bot has not cached. If the bridge does not fetch them first it + * builds a context out of nulls — or drops the event entirely — and every + * reaction automation on an older message stays silently broken. + */ +describe("eventBridge resolves partials before building a context", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRulesForEvent.mockReturnValue([{ id: 1 }]); + }); + + it("fetches a partial reaction before processing reactionAdded", async () => { + const { client, handlers } = fakeClient(); + registerActionEventListeners(client as never); + const reaction = partialReaction(); + + await handlers.get("messageReactionAdd")!(reaction, { id: "u1", bot: false, username: "ada" }); + + expect(reaction.fetch).toHaveBeenCalled(); + expect(mockProcessEvent).toHaveBeenCalled(); + }); + + it("fetches a partial user so the context carries a real username", async () => { + const { client, handlers } = fakeClient(); + registerActionEventListeners(client as never); + const user = { id: "u1", bot: false, partial: true, fetch: vi.fn().mockResolvedValue({ id: "u1", username: "ada", tag: "ada#1" }) }; + + await handlers.get("messageReactionAdd")!(partialReaction(), user); + + expect(user.fetch).toHaveBeenCalled(); + }); + + it("drops the event rather than throwing when the message is gone", async () => { + const { client, handlers } = fakeClient(); + registerActionEventListeners(client as never); + const reaction = partialReaction({ + fetch: vi.fn().mockRejectedValue(new Error("Unknown Message")), + }); + + await expect( + handlers.get("messageReactionAdd")!(reaction, { id: "u1", bot: false }), + ).resolves.not.toThrow(); + expect(mockProcessEvent).not.toHaveBeenCalled(); + }); + + it("still processes reactionRemoved for a partial reaction", async () => { + const { client, handlers } = fakeClient(); + registerActionEventListeners(client as never); + const reaction = partialReaction(); + + await handlers.get("messageReactionRemove")!(reaction, { id: "u1", bot: false }); + + expect(reaction.fetch).toHaveBeenCalled(); + expect(mockProcessEvent).toHaveBeenCalled(); + }); + + // A deleted message cannot be fetched back, so the bridge must not treat a + // null author as "this is a bot, skip it" — that would drop every + // messageDeleted event for an uncached message. + it("processes messageDeleted for a partial message with no author", async () => { + const { client, handlers } = fakeClient(); + registerActionEventListeners(client as never); + + await handlers.get("messageDelete")!({ + partial: true, + guildId: "g1", + guild: { name: "G", memberCount: 5 }, + channelId: "c1", + channel: { name: "general" }, + id: "m1", + author: null, + content: null, + }); + + expect(mockProcessEvent).toHaveBeenCalled(); + }); +}); + +describe("eventBridge threadCreated matches on the parent channel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRulesForEvent.mockReturnValue([{ id: 1 }]); + }); + + // context.channelId is the NEW thread, so "only threads under #support" + // could never match. The parent has to travel with the context for a channel + // filter on threadCreated to mean anything. + it("carries the parent channel id in the event context", async () => { + const { client, handlers } = fakeClient(); + registerActionEventListeners(client as never); + + await handlers.get("threadCreate")!({ + guildId: "g1", + guild: { name: "G", memberCount: 5 }, + id: "t1", + name: "help", + ownerId: "u1", + parentId: "parent-1", + }); + + const ctx = mockProcessEvent.mock.calls[0][1]; + expect(ctx.parentChannelId).toBe("parent-1"); + }); +}); diff --git a/apps/bot/tests/features/automation/system/executor.test.ts b/apps/bot/tests/features/automation/system/executor.test.ts index b5ad4cef..b5d83a34 100644 --- a/apps/bot/tests/features/automation/system/executor.test.ts +++ b/apps/bot/tests/features/automation/system/executor.test.ts @@ -329,6 +329,43 @@ describe("action executor - filters fail closed", () => { expect(mockSendMessageExecutor).toHaveBeenCalled(); }); + + // On threadCreated the context channel is the brand-new thread, so a channel + // filter could only ever match an id the user has no way to know. The parent + // is what they actually picked in the editor. + it("matches a channel filter against the parent channel", async () => { + const threadCtx = { + eventType: "threadCreated" as const, + guildId: "guild-123", + guildName: "Test Guild", + channelId: "thread-new", + parentChannelId: "support", + memberCount: 100, + timestamp: new Date().toISOString(), + }; + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ channelIds: ["support"] })); + + await processEvent({} as never, threadCtx); + + expect(mockSendMessageExecutor).toHaveBeenCalled(); + }); + + it("excludes on the parent channel too", async () => { + const threadCtx = { + eventType: "threadCreated" as const, + guildId: "guild-123", + guildName: "Test Guild", + channelId: "thread-new", + parentChannelId: "support", + memberCount: 100, + timestamp: new Date().toISOString(), + }; + mockGetRulesForEvent.mockReturnValueOnce(ruleWith({ excludeChannelIds: ["support"] })); + + await processEvent({} as never, threadCtx); + + expect(mockSendMessageExecutor).not.toHaveBeenCalled(); + }); }); describe("action executor - the log tells the truth", () => { diff --git a/apps/bot/tests/shared/client/ExtendedClient.test.ts b/apps/bot/tests/shared/client/ExtendedClient.test.ts index 0e660021..9b17e097 100644 --- a/apps/bot/tests/shared/client/ExtendedClient.test.ts +++ b/apps/bot/tests/shared/client/ExtendedClient.test.ts @@ -11,7 +11,7 @@ vi.mock("@fluxcore/config", () => ({ })); import { ExtendedClient } from "../../../src/shared/client/ExtendedClient.js"; -import { Collection } from "discord.js"; +import { Collection, Partials } from "discord.js"; describe("ExtendedClient", () => { it("instantiates without errors", () => { @@ -26,4 +26,21 @@ describe("ExtendedClient", () => { expect(client.commands.size).toBe(0); client.destroy(); }); + + // Without partials, discord.js drops any gateway event whose primary + // structure is not already cached. The bot only caches messages seen since + // its last restart, so reactionAdded / reactionRemoved / messageDeleted + // never reached a handler for an older message — which silently breaks + // every reaction-role automation attached to a pinned rules message. + it("enables the partials the reaction and message-delete triggers need", () => { + const client = new ExtendedClient(); + + const partials = client.options.partials ?? []; + expect(partials).toContain(Partials.Message); + expect(partials).toContain(Partials.Reaction); + expect(partials).toContain(Partials.Channel); + expect(partials).toContain(Partials.User); + + client.destroy(); + }); }); \ No newline at end of file diff --git a/apps/dashboard/src/client/features/automation/components/ActionFields.tsx b/apps/dashboard/src/client/features/automation/components/ActionFields.tsx index d9e13088..b9e545cd 100644 --- a/apps/dashboard/src/client/features/automation/components/ActionFields.tsx +++ b/apps/dashboard/src/client/features/automation/components/ActionFields.tsx @@ -15,6 +15,7 @@ import { import { VariableEditor } from "../../../shared/ui/variable-field"; import type { VariableDescriptor } from "../../../shared/ui/variable-field"; import type { ActionFieldDescriptor, Channel, Role } from "../../../shared/lib/schemas"; +import { channelIconName, isMessageableChannel } from "../../../shared/lib/channelTypes"; const VARIABLE_FIELD_KEYS = new Set([ "message", @@ -86,13 +87,13 @@ export function ActionFields({ searchPlaceholder={t("form.search")} noResultsLabel={t("form.noResults")} options={channels - .filter((c) => c.type === 0 || c.type === 2) + .filter((c) => isMessageableChannel(c.type)) .map((c) => ({ value: c.id, label: c.name, icon: ( diff --git a/apps/dashboard/src/client/shared/lib/channelTypes.ts b/apps/dashboard/src/client/shared/lib/channelTypes.ts new file mode 100644 index 00000000..6b47b4da --- /dev/null +++ b/apps/dashboard/src/client/shared/lib/channelTypes.ts @@ -0,0 +1,84 @@ +/** + * Discord channel-type numbers and the one definition of "a channel a message + * or a filter can target". + * + * Three components previously each hardcoded `c.type === 0 || c.type === 2`, + * which silently excluded announcement, stage, forum and media channels + * everywhere: a rule could not be scoped to an announcement channel, and a + * Send Message action could not target one. Import from here instead. + * + * Values mirror Discord's ChannelType enum. We don't import discord.js into + * the browser bundle for six integers. + */ +export const CHANNEL_TYPE = { + GuildText: 0, + GuildVoice: 2, + GuildCategory: 4, + GuildAnnouncement: 5, + AnnouncementThread: 10, + PublicThread: 11, + PrivateThread: 12, + GuildStageVoice: 13, + GuildForum: 15, + GuildMedia: 16, +} as const; + +/** + * Channels that can hold messages, and so can be picked as a Send Message / + * Log to Channel target or as a trigger filter. + * + * Threads are excluded deliberately: they are transient, there can be + * thousands, and a filter should name the parent (which + * `EventContext.parentChannelId` now carries) rather than one thread. + * Categories are excluded because they hold no messages of their own. + */ +const MESSAGEABLE: ReadonlySet = new Set([ + CHANNEL_TYPE.GuildText, + CHANNEL_TYPE.GuildVoice, + CHANNEL_TYPE.GuildAnnouncement, + CHANNEL_TYPE.GuildStageVoice, + CHANNEL_TYPE.GuildForum, + CHANNEL_TYPE.GuildMedia, +]); + +export function isMessageableChannel(type: number): boolean { + return MESSAGEABLE.has(type); +} + +/** Icon name (see shared/components/Icon) for a channel type. */ +export function channelIconName(type: number): string { + switch (type) { + case CHANNEL_TYPE.GuildVoice: + return "volume_up"; + case CHANNEL_TYPE.GuildStageVoice: + return "podcasts"; + case CHANNEL_TYPE.GuildAnnouncement: + return "campaign"; + case CHANNEL_TYPE.GuildForum: + case CHANNEL_TYPE.GuildMedia: + return "forum"; + case CHANNEL_TYPE.GuildCategory: + return "folder"; + default: + return "hash"; + } +} + +/** Text prefix used where an icon component cannot be rendered. */ +export function channelLabelPrefix(type: number): string { + switch (type) { + case CHANNEL_TYPE.GuildVoice: + return "🔊"; + case CHANNEL_TYPE.GuildStageVoice: + return "🎙️"; + case CHANNEL_TYPE.GuildAnnouncement: + return "📢"; + case CHANNEL_TYPE.GuildForum: + case CHANNEL_TYPE.GuildMedia: + return "💬"; + case CHANNEL_TYPE.GuildCategory: + return "📁"; + default: + return "#"; + } +} diff --git a/apps/dashboard/src/client/shared/ui/discord-multi-select.tsx b/apps/dashboard/src/client/shared/ui/discord-multi-select.tsx index f9f40868..5ac562d3 100644 --- a/apps/dashboard/src/client/shared/ui/discord-multi-select.tsx +++ b/apps/dashboard/src/client/shared/ui/discord-multi-select.tsx @@ -5,6 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "./popover"; import { SelectSkeleton } from "./skeletons"; import { Label } from "./label"; import { cn } from "../lib/utils"; +import { CHANNEL_TYPE, channelLabelPrefix, isMessageableChannel } from "../lib/channelTypes"; export type DiscordMultiSelectType = "text" | "voice" | "any" | "role"; @@ -18,7 +19,7 @@ export interface DiscordMultiSelectProps { } function channelLabel(name: string, channelType: number): string { - return channelType === 2 ? `🔊 ${name}` : `# ${name}`; + return `${channelLabelPrefix(channelType)} ${name}`; } export function DiscordMultiSelect({ @@ -54,9 +55,12 @@ export function DiscordMultiSelect({ ? (roles ?? []).map((r) => ({ id: r.id, name: `● ${r.name}` })) : (channels ?? []) .filter((c) => { - if (type === "text") return c.type === 0; - if (type === "voice") return c.type === 2; - return c.type === 0 || c.type === 2; + if (type === "text") return c.type === CHANNEL_TYPE.GuildText; + if (type === "voice") return c.type === CHANNEL_TYPE.GuildVoice; + // "any" means any channel a message or a filter can target — + // including announcement, stage, forum and media, which a + // hardcoded `type === 0 || type === 2` silently excluded. + return isMessageableChannel(c.type); }) .map((c) => ({ id: c.id, name: channelLabel(c.name, c.type) })), [isRole, roles, channels, type], diff --git a/apps/dashboard/tests/client/shared/lib/channelTypes.test.ts b/apps/dashboard/tests/client/shared/lib/channelTypes.test.ts new file mode 100644 index 00000000..2d73b790 --- /dev/null +++ b/apps/dashboard/tests/client/shared/lib/channelTypes.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { + CHANNEL_TYPE, + isMessageableChannel, + channelIconName, +} from "../../../../src/client/shared/lib/channelTypes"; + +/** + * Three components each hardcoded their own idea of "a channel you can post + * to" (`c.type === 0 || c.type === 2`), which silently excluded announcement, + * stage and forum channels everywhere. A rule could not be scoped to an + * announcement channel, and a Send Message action could not target one. + */ +describe("isMessageableChannel", () => { + it("accepts every channel type a message or filter can target", () => { + for (const type of [ + CHANNEL_TYPE.GuildText, + CHANNEL_TYPE.GuildVoice, + CHANNEL_TYPE.GuildAnnouncement, + CHANNEL_TYPE.GuildStageVoice, + CHANNEL_TYPE.GuildForum, + CHANNEL_TYPE.GuildMedia, + ]) { + expect(isMessageableChannel(type), String(type)).toBe(true); + } + }); + + it("rejects categories, which hold no messages", () => { + expect(isMessageableChannel(CHANNEL_TYPE.GuildCategory)).toBe(false); + }); + + it("rejects unknown types rather than guessing", () => { + expect(isMessageableChannel(999)).toBe(false); + }); +}); + +describe("channelIconName", () => { + it("distinguishes voice, stage, announcement, forum and text", () => { + const names = new Set([ + channelIconName(CHANNEL_TYPE.GuildVoice), + channelIconName(CHANNEL_TYPE.GuildStageVoice), + channelIconName(CHANNEL_TYPE.GuildAnnouncement), + channelIconName(CHANNEL_TYPE.GuildForum), + channelIconName(CHANNEL_TYPE.GuildText), + ]); + expect(names.size).toBe(5); + }); + + it("falls back to the text icon for anything unrecognised", () => { + expect(channelIconName(999)).toBe(channelIconName(CHANNEL_TYPE.GuildText)); + }); +}); diff --git a/packages/systems/src/actions/types.ts b/packages/systems/src/actions/types.ts index 202448dc..50dd471b 100644 --- a/packages/systems/src/actions/types.ts +++ b/packages/systems/src/actions/types.ts @@ -99,6 +99,14 @@ export interface EventContext { channelId?: string; channelName?: string; channelMention?: string; + /** + * The parent of `channelId`, when the event's channel is itself nested — + * currently only threadCreated, where `channelId` is the brand-new thread. + * Channel filters test this too, so "only threads under #support" is + * expressible; without it a channel filter on threadCreated could never + * match anything the user is able to pick. + */ + parentChannelId?: string; roleId?: string; roleName?: string; roleMention?: string; From 3e9b6f28840b7f7e852a16099a36940ebf1c5c43 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Mon, 27 Jul 2026 22:41:25 +0300 Subject: [PATCH 10/19] fix(automation): stop the editor losing work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ways the canvas discarded edits with no warning. **Changing an action's type erased every filled field.** handleTypeChange reset to `{ type }`, so composing a long welcome message under Send Message and switching to Send DM lost it the instant the dropdown closed — both types declare `message` — with no undo, and the draft autosave immediately persisting the emptied action. carryOverActionFields now keeps any value whose key the new type also declares and drops the rest. **Escape inside a text field closed the whole editor.** The hotkey layer explicitly let Escape through from INPUT/TEXTAREA/SELECT, and Escape with nothing selected is the close binding. Escape now belongs to the focused control (blur and stop); only Ctrl/Cmd+S still passes through. **Multi-select delete removed the wrong actions.** React Flow sends one `remove` change per node and each was applied by index in a loop, but every removal splices the array — deleting nodes 0 and 2 removed actions 0 and 3. Added handleActionsRemove to drop the whole set in one pass. **Closing discarded unsaved edits silently.** The autosave wrote a draft for every session, but loadDraft is only consulted for NEW rules, so editing an existing rule produced an unread draft while the edits were simply lost. Drafts are now written only where they can be read back, and every close path (back button, Escape, tab close) goes through a dirty check — an in-app confirm plus a beforeunload guard. Also gives the action-type picker an accessible name; `