Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 f30a65eb74 fix(status): banner Details strips Markdown syntax, not every #, _, >, * (#308)
CI / Build & Quality Checks (pull_request) Successful in 3m20s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m3s
plain() deleted those characters everywhere, so "join #lotus-general"
lost its #, snake_case and @some_user lost underscores, "->" became
"-", "5 * 3" became "5  3", and "<b>" became "<b". Strip only syntax:
heading / quote / list markers at line start, paired **x** *x* _x_ `x`,
and HTML tags.

Fixes #308

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:22:12 -04:00
5 changed files with 72 additions and 71 deletions
@@ -17,7 +17,6 @@ import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
// ── Policy event types ────────────────────────────────────────────────────────
@@ -39,6 +38,13 @@ type PolicyRuleContent = {
recommendation?: string;
};
type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
type PolicyTab = 'users' | 'rooms' | 'servers';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -47,6 +53,13 @@ function isGlob(entity: string): boolean {
return entity.includes('*') || entity.includes('?');
}
function recommendationLabel(rec: string): string {
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
// (pre-stabilization) recommendation still emitted by older bots.
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
return rec;
}
/**
* Fetch all state events of a given type from a room's live forward state.
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
@@ -71,6 +84,23 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== '');
}
/**
* Merge policy entries from the stable and legacy event types for a rule
* kind, de-duplicating by entity+recommendation so a room that emits both a
* stable and a legacy rule for the same target isn't double-listed.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${entry.recommendation}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -1,32 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupePolicyEntries, recommendationLabel } from './policyEntries';
const e = (entity: string, recommendation: string, stateKey = entity) => ({
entity,
reason: 'spam waves',
recommendation,
stateKey,
});
test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => {
const out = dedupePolicyEntries([
e('@spammer:evil.example', 'm.ban'),
e('@spammer:evil.example', 'org.matrix.mjolnir.ban'),
e('@*:spam.example', 'm.ban'),
]);
assert.deepEqual(
out.map((x) => x.entity),
['@spammer:evil.example', '@*:spam.example'],
);
});
test('dedupePolicyEntries: different recommendations stay separate', () => {
assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2);
});
test('recommendationLabel', () => {
assert.equal(recommendationLabel('m.ban'), 'Ban');
assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban');
assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute');
});
@@ -1,35 +0,0 @@
export type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
/**
* `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
* (pre-stabilization) recommendation still emitted by older bots.
*/
export const normalizeRecommendation = (rec: string): string =>
rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec;
export function recommendationLabel(rec: string): string {
if (normalizeRecommendation(rec) === 'm.ban') return 'Ban';
return rec;
}
/**
* Merge stable and legacy rule lists, one entry per entity + recommendation.
* [Gitea #321] Keyed on the normalised recommendation: a bot publishing both
* `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
+25
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
plain,
DEFAULT_GROUPS,
kumaBeatTime,
kumaUrls,
@@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
'server-down',
);
});
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
assert.equal(
plain('join #lotus-general or #announcements:lotusguild.org'),
'join #lotus-general or #announcements:lotusguild.org',
);
assert.equal(
plain('ping @some_user about snake_case and C#'),
'ping @some_user about snake_case and C#',
);
assert.equal(
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
'calls -> fixed, > 5 people, 5 * 3 = 15',
);
assert.equal(
plain('# Heading\n* item one\n- item two\n> quoted'),
'Heading\nitem one\nitem two\nquoted',
);
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
assert.equal(
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
'Read the guide docs',
);
});
+16 -3
View File
@@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => {
};
/** Markdown-ish incident text as plain text for the Details line. */
const plain = (s: string): string =>
/**
* [Gitea #308] Markdown → plain text for the Details line. Strips only the
* syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case",
* "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway).
*/
export const plain = (s: string): string =>
s
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`#>]/g, '')
.replace(/\s+\n/g, '\n')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*[*+-][ \t]+/gm, '')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1')
.replace(/__(?=\S)([^_\n]*?\S)__/g, '$1')
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2')
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
.replace(/[ \t]+\n/g, '\n')
.trim();
/**