Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
07a8c3c
docs(automation): record the rules UX & correctness audit
etqanagencycompany-tech Jul 27, 2026
72eca53
fix(automation): block saving actions with empty required fields
etqanagencycompany-tech Jul 27, 2026
5ce9ad3
fix(automation): scope action variables to the trigger, restore them …
etqanagencycompany-tech Jul 27, 2026
e12a064
fix(automation): make trigger filters fail closed
etqanagencycompany-tech Jul 27, 2026
31b1952
feat(automation): only offer trigger filters the trigger can evaluate
etqanagencycompany-tech Jul 27, 2026
6369313
i18n(rules): translate the trigger-filter condition keys in all 47 lo…
etqanagencycompany-tech Jul 27, 2026
fa1026f
i18n(rules): translate the condition chip remove label in 38 locales
etqanagencycompany-tech Jul 27, 2026
6cb8b6e
fix(automation): stop reporting phantom successes at runtime
etqanagencycompany-tech Jul 27, 2026
790b4d2
fix(automation): make reaction, thread and announcement triggers actu…
etqanagencycompany-tech Jul 27, 2026
3e9b6f2
fix(automation): stop the editor losing work
etqanagencycompany-tech Jul 27, 2026
88c77b3
fix(automation): stop advertising template variables the bot never fi…
etqanagencycompany-tech Jul 27, 2026
e012e1d
fix(automation): make the webhook Headers field savable, and say whic…
etqanagencycompany-tech Jul 27, 2026
667044e
test(automation): cover the v2 step-graph execution path
etqanagencycompany-tech Jul 27, 2026
a1737dc
fix(automation): harden the rules API — names, conditions, duplicate …
etqanagencycompany-tech Jul 27, 2026
c3b9209
fix(automation): make validation problems findable
etqanagencycompany-tech Jul 27, 2026
99d513f
fix(automation): surface incomplete rules, cap step mode, label the f…
etqanagencycompany-tech Jul 27, 2026
f882320
feat(automation): give trigger filters a real member picker
etqanagencycompany-tech Jul 27, 2026
9fb5142
i18n(automation): make the automation vocabulary translatable
etqanagencycompany-tech Jul 27, 2026
13ccdf4
i18n(automation): translate trigger names and descriptions (af-fr)
etqanagencycompany-tech Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions apps/bot/src/features/automation/system/eventBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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) => {
Expand Down Expand Up @@ -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: {
Expand Down
76 changes: 56 additions & 20 deletions apps/bot/src/features/automation/system/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,60 @@ 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) {
if (!conditions.channelIds.includes(context.channelId)) return false;
// 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 (channelIds.length === 0) return false;
if (!channelIds.some((id) => conditions.channelIds!.includes(id))) 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) {
if (conditions.excludeChannelIds.includes(context.channelId)) return false;
// Exclude filters: an unanswerable exclusion cannot be cleared, so it blocks.
if (conditions.excludeChannelIds?.length) {
if (channelIds.length === 0) return false;
if (channelIds.some((id) => conditions.excludeChannelIds!.includes(id))) 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),
);
Expand Down Expand Up @@ -111,9 +139,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);
Expand All @@ -137,10 +177,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(",")
Expand Down Expand Up @@ -188,12 +224,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 =
Expand Down
Loading
Loading