Draupnir news system (#965)
Some checks failed
Docker Hub - Develop / docker-latest (push) Has been cancelled
Tests / Build & Lint (push) Has been cancelled
Tests / Unit tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Application Service Integration tests (push) Has been cancelled

* Add infrastructure for testing Draupnir news.

https://github.com/the-draupnir-project/planning/issues/56.

* Update protections for new PermalinkSchema.

https://github.com/the-draupnir-project/planning/issues/57

* Add a way to announce Draupnir longhouse assemblies.

This is kind of stupid though we should have just made a generic news
system that deals with actual events pulled from the static blog
and just sent into the room...

https://github.com/the-draupnir-project/planning/issues/56.

* Update news to just use a blob in the repository.

https://github.com/the-draupnir-project/planning/issues/56.

* Simplify seen news mechanism.

https://github.com/the-draupnir-project/planning/issues/56.

* Cut some dependencies out of DraupnirNews for unit testing.

https://github.com/the-draupnir-project/planning/issues/58

* Rename the longhouse assembly thing to be a generic news reader.

It was already changed to be generic we just forogt the name.
https://github.com/the-draupnir-project/planning/issues/58.

* Improve code quality of DraupnirNews.

No way is this being tested without being a lot neater.  The problem
is that any test was going to be too coupled to implementation due to
the shared responsibilities of the old class.

https://github.com/the-draupnir-project/planning/issues/58.

* Add DraupnirNews unit test.

https://github.com/the-draupnir-project/planning/issues/58.

* Allow filesystem news to show when remote news fails to fetch.

Discovery from https://github.com/the-draupnir-project/planning/issues/58.

* Add a comment about how news gets cleaned up.
This commit is contained in:
Gnuxie
2025-10-09 16:49:53 +01:00
committed by GitHub
parent fb14cea361
commit b73cfd7907
15 changed files with 445 additions and 14 deletions

View File

@@ -139,6 +139,8 @@ protections:
roomStateBackingStore:
enabled: false
draupnirNewsURL: http://127.0.0.1:8081/draupnir_news.json
# Options for monitoring the health of the bot
health:
# healthz options. These options are best for use in container environments

View File

@@ -17,6 +17,8 @@ up:
# Launch the reverse proxy, listening for connections *only* on the local host.
- docker run --rm --network host --name mjolnir-test-reverse-proxy -p
127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro
-v
$MX_TEST_CWD/test/draupnir_news.json:/var/www/test/draupnir_news.json:ro
-d nginx
- corepack yarn install
- corepack yarn ts-node src/appservice/cli.ts -r -u

View File

@@ -52,7 +52,7 @@
"@sentry/node": "^7.17.2",
"@sinclair/typebox": "0.34.13",
"@the-draupnir-project/interface-manager": "4.2.5",
"@the-draupnir-project/matrix-basic-types": "1.4.0",
"@the-draupnir-project/matrix-basic-types": "1.4.1",
"@the-draupnir-project/mps-interface-adaptor": "0.5.1",
"better-sqlite3": "^9.4.3",
"body-parser": "^1.20.2",
@@ -63,7 +63,7 @@
"jsdom": "^24.0.0",
"matrix-appservice-bridge": "^10.3.1",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.13.0",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@4.0.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.12.0",
"pg": "^8.8.0",
"yaml": "^2.3.2"

View File

@@ -169,6 +169,9 @@ export interface IConfig {
// This can not be used with Pantalaimon.
experimentalRustCrypto: boolean;
// Where to fetch news from
draupnirNewsURL: string;
configMeta:
| {
/**
@@ -262,6 +265,8 @@ const defaultConfig: IConfig = {
enabled: true,
},
experimentalRustCrypto: false,
draupnirNewsURL:
"https://raw.githubusercontent.com/the-draupnir-project/Draupnir/refs/heads/main/src/protections/DraupnirNews/news.json",
configMeta: undefined,
};

View File

@@ -0,0 +1,295 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { readFileSync } from "fs";
import { isOk, Ok, Result } from "@gnuxie/typescript-result";
import { Type } from "@sinclair/typebox";
import {
AbstractProtection,
ActionException,
ActionExceptionKind,
ConstantPeriodBatch,
describeProtection,
EDStatic,
isError,
Logger,
MessageContent,
ProtectedRoomsSet,
ProtectionDescription,
StandardTimedGate,
Value,
} from "matrix-protection-suite";
import { Draupnir } from "../../Draupnir";
import { DraupnirProtection } from "../Protection";
import path from "path";
const log = new Logger("DraupnirNews");
// TODO:
// We should probably allow tagging these e.g. to assist making an automated system
// for adding release news.
export type DraupnirNewsItem = EDStatic<typeof DraupnirNewsItem>;
export const DraupnirNewsItem = Type.Object({
news_id: Type.String({
description: "An identifier that can be persisted for an item of news.",
}),
matrix_event_content: Type.Union([MessageContent], {
description: "Matrix event content for the news item that can be sent",
}),
});
export type DraupnirNewsBlob = EDStatic<typeof DraupnirNewsBlob>;
export const DraupnirNewsBlob = Type.Object({
news: Type.Array(DraupnirNewsItem),
});
export const DraupnirNewsHelper = Object.freeze({
mergeSources(...blobs: DraupnirNewsBlob[]): DraupnirNewsItem[] {
return [
...new Map(
blobs
.reduce<DraupnirNewsItem[]>((acc, blob) => [...acc, ...blob.news], [])
.map((item) => [item.news_id, item])
).values(),
];
},
removeSeenNews(news: DraupnirNewsItem[], seenNewsIDs: Set<string>) {
return news.filter((item) => !seenNewsIDs.has(item.news_id));
},
removeUnseenNews(news: DraupnirNewsItem[], seenNewsIDs: Set<string>) {
return news.filter((item) => seenNewsIDs.has(item.news_id));
},
});
export type StoreSeenNews = (
seenNews: DraupnirNewsItem[]
) => Promise<Result<void>>;
export type FetchRemoteNews = () => Promise<Result<DraupnirNewsBlob>>;
export type NotifyNewsItem = (item: DraupnirNewsItem) => Promise<Result<void>>;
/**
* This class manages requesting news from upstream, notifying, and storing
* the newly seen news. Once seen news is updated, the instance should be
* disposed.
*/
export class DraupnirNewsLifecycle {
public constructor(
private readonly seenNewsIDs: Set<string>,
private readonly localNews: DraupnirNewsBlob,
private readonly storeNews: StoreSeenNews,
private readonly fetchRemoteNews: FetchRemoteNews,
private readonly notifyNewsItem: NotifyNewsItem
) {
// nothing to do.
}
public async checkForNews(): Promise<void> {
const remoteNews = await this.fetchRemoteNews();
if (isError(remoteNews)) {
log.error("Unable to fetch news blob", remoteNews.error);
// fall through, we still want to be able to show filesystem news.
}
const allNews = DraupnirNewsHelper.mergeSources(
this.localNews,
isOk(remoteNews) ? remoteNews.ok : { news: [] }
);
const unseenNews = DraupnirNewsHelper.removeSeenNews(
allNews,
this.seenNewsIDs
);
const notifiedNews = DraupnirNewsHelper.removeUnseenNews(
allNews,
this.seenNewsIDs
);
for (const item of unseenNews) {
const sendResult = await this.notifyNewsItem(item);
if (isError(sendResult)) {
log.error("Unable to notify of news item");
} else {
notifiedNews.push(item);
}
}
const updateResult = await this.storeNews(notifiedNews);
if (isError(updateResult)) {
log.error("Unable to update stored news", updateResult.error);
return;
}
}
}
const FSNews = (() => {
const content = JSON.parse(
readFileSync(path.join(__dirname, "./news.json"), "utf8")
);
return Value.Decode(DraupnirNewsBlob, content).expect(
"File system news should match the schema"
);
})();
async function fetchNews(newsURL: string): Promise<Result<DraupnirNewsBlob>> {
return await fetch(newsURL, {
method: "GET",
headers: {
Accept: "application/json",
},
})
.then((response) => response.json())
.then(
(json) => Value.Decode(DraupnirNewsBlob, json),
(error) =>
ActionException.Result("unable to fetch news", {
exception: error,
exceptionKind: ActionExceptionKind.Unknown,
})
);
}
/**
* This class schedules when to request news from the upstream repository.
*
* Lifecycle:
* - unregisterListeners MUST be called when the parent protection is disposed.
*/
export class DraupnirNewsReader {
private readonly newsGate = new StandardTimedGate(
this.requestNews.bind(this),
this.requestIntervalMS
);
private requestLoop: ConstantPeriodBatch;
public constructor(
private readonly lifecycle: DraupnirNewsLifecycle,
private readonly requestIntervalMS: number
) {
this.newsGate.enqueueOpen();
this.requestLoop = this.createRequestLoop();
}
private createRequestLoop(): ConstantPeriodBatch {
return new ConstantPeriodBatch(() => {
this.newsGate.enqueueOpen();
this.requestLoop = this.createRequestLoop();
}, this.requestIntervalMS);
}
private async requestNews(): Promise<void> {
await this.lifecycle.checkForNews();
}
public unregisterListeners(): void {
this.newsGate.destroy();
this.requestLoop.cancel();
}
}
// Seen news gets cleaned up by storing the merged file system and remote
// news items which have been notified.
export const DraupnirNewsProtectionSettings = Type.Object(
{
seenNews: Type.Array(Type.String(), {
default: [],
uniqueItems: true,
description: "Any news items that have been seen by the protection.",
}),
},
{
title: "DraupnirNewsProtectionSettings",
}
);
export type DraupnirNewsProtectionCapabilities = Record<string, never>;
export type DraupnirNewsProtectionSettings = EDStatic<
typeof DraupnirNewsProtectionSettings
>;
export type DraupnirNewsDescription = ProtectionDescription<
Draupnir,
typeof DraupnirNewsProtectionSettings,
DraupnirNewsProtectionCapabilities
>;
export class DraupnirNews
extends AbstractProtection<DraupnirNewsDescription>
implements DraupnirProtection<DraupnirNewsDescription>
{
private readonly newsReader = new DraupnirNewsReader(
new DraupnirNewsLifecycle(
new Set(this.settings.seenNews),
FSNews,
this.updateNews.bind(this),
() => fetchNews(this.draupnir.config.draupnirNewsURL),
(item) =>
this.draupnir.clientPlatform
.toRoomMessageSender()
.sendMessage(
this.draupnir.managementRoomID,
item.matrix_event_content
) as Promise<Result<void>>
),
4.32e7 // 12 hours
);
public constructor(
description: DraupnirNewsDescription,
capabilities: DraupnirNewsProtectionCapabilities,
protectedRoomsSet: ProtectedRoomsSet,
private readonly settings: DraupnirNewsProtectionSettings,
private readonly draupnir: Draupnir
) {
super(description, capabilities, protectedRoomsSet, {});
}
private async updateNews(allNews: DraupnirNewsItem[]): Promise<Result<void>> {
const newSettings = this.description.protectionSettings.toMirror().setValue(
this.settings,
"seenNews",
allNews.map((item) => item.news_id)
);
if (isError(newSettings)) {
return newSettings.elaborate("Unable to set protection settings");
}
const result =
await this.protectedRoomsSet.protections.changeProtectionSettings(
this.description as unknown as ProtectionDescription,
this.protectedRoomsSet,
this.draupnir,
newSettings.ok
);
if (isError(result)) {
return result.elaborate("Unable to change protection settings");
}
return Ok(undefined);
}
handleProtectionDisable(): void {
this.newsReader.unregisterListeners();
}
}
describeProtection<
DraupnirNewsProtectionCapabilities,
Draupnir,
typeof DraupnirNewsProtectionSettings
>({
name: DraupnirNews.name,
description: "Provides news about the Draupnir project.",
capabilityInterfaces: {},
defaultCapabilities: {},
configSchema: DraupnirNewsProtectionSettings,
async factory(
description,
protectedRoomsSet,
draupnir,
capabilities,
settings
) {
return Ok(
new DraupnirNews(
description,
capabilities,
protectedRoomsSet,
settings,
draupnir
)
);
},
});

View File

@@ -0,0 +1,3 @@
{
"news": []
}

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
#
# SPDX-License-Identifier: CC0-1.0

View File

@@ -14,6 +14,7 @@ import "../capabilities/capabilityIndex";
// keep alphabetical please.
import "./BanPropagation";
import "./BasicFlooding";
import "./DraupnirNews/DraupnirNews";
import "./FirstMessageIsImage";
import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection";
import "./InvalidEventProtection";

View File

@@ -17,7 +17,7 @@ import {
Task,
Value,
isError,
PermalinkSchema,
RoomIDPermalinkSchema,
} from "matrix-protection-suite";
import {
renderActionResultToEvent,
@@ -40,7 +40,7 @@ const PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER =
// would be nice to be able to use presentation types here idk.
const ProtectRoomsOnInvitePromptContext = Type.Object({
invited_room: PermalinkSchema,
invited_room: RoomIDPermalinkSchema,
});
// this rule is stupid.

View File

@@ -14,9 +14,9 @@ import {
MJOLNIR_SHORTCODE_EVENT_TYPE,
MembershipEvent,
Ok,
PermalinkSchema,
ProtectedRoomsSet,
RoomEvent,
RoomIDPermalinkSchema,
RoomStateRevision,
Task,
Value,
@@ -43,7 +43,7 @@ const WATCH_LISTS_ON_INVITE_PROMPT_LISTENER =
// would be nice to be able to use presentation types here idk.
const WatchRoomsOnInvitePromptContext = Type.Object({
invited_room: PermalinkSchema,
invited_room: RoomIDPermalinkSchema,
});
// this rule is stupid.

11
test/draupnir_news.json Normal file
View File

@@ -0,0 +1,11 @@
{
"news": [
{
"news_id": "59e0dd6e-87da-4459-98ae-627c0f2a7d8b",
"matrix_event_content": {
"body": "Announcing the Draupnir Longhouse Assembly! https://matrix.to/#/!DtwZFWORUIApKsOVWi:matrix.org/%24GdBN1XqoOnAfc5tJgxhoXNoAdW2YUbS1Mtsb8LbzIJ4?via=matrix.org&via=feline.support&via=asgard.chat",
"msgtype": "m.notice"
}
}
]
}

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
#
# SPDX-License-Identifier: CC0-1.0

View File

@@ -37,5 +37,10 @@ http {
internal;
proxy_pass http://127.0.0.1:9999$request_uri;
}
# draupnir news blob
location /draupnir_news.json {
root /var/www/test;
default_type application/json;
}
}
}

View File

@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { Ok, ResultError } from "@gnuxie/typescript-result";
import {
DraupnirNewsBlob,
DraupnirNewsLifecycle,
} from "../../../src/protections/DraupnirNews/DraupnirNews";
import expect from "expect";
describe("DraupnirNewsTest", function () {
it("Filesystem news items get sent if the protection hasn't seen them before", async function () {
const fileSystemNews = {
news: [
{
news_id: "1",
matrix_event_content: {
body: "Announcing release v3.0.0!! wohoo",
msgtype: "m.text",
},
},
{
news_id: "2",
matrix_event_content: {
body: "Draupnir needs your support!",
msgtype: "m.text",
},
},
],
} satisfies DraupnirNewsBlob;
const remoteNews = {
news: [
{
news_id: "3",
matrix_event_content: {
body: "Announcing draupnir news",
msgtype: "m.text",
},
},
],
} satisfies DraupnirNewsBlob;
const seenNews = new Set<string>();
const notifiedNews: string[] = [];
const newsLifecycle = new DraupnirNewsLifecycle(
seenNews,
fileSystemNews,
async (allNews) => {
allNews.forEach((item) => seenNews.add(item.news_id));
return Ok(undefined);
},
async () => Ok(remoteNews),
async (item) => {
notifiedNews.push(item.news_id);
return Ok(undefined);
}
);
expect(seenNews.size).toBe(0);
expect(notifiedNews.length).toBe(0);
await newsLifecycle.checkForNews();
expect(seenNews.size).toBe(3);
expect(notifiedNews.length).toBe(3);
await newsLifecycle.checkForNews();
expect(notifiedNews.length).toBe(3);
});
it("Still works if remote news is inaccessible", async function () {
const fileSystemNews = {
news: [
{
news_id: "1",
matrix_event_content: {
body: "Announcing release v3.0.0!! wohoo",
msgtype: "m.text",
},
},
],
} satisfies DraupnirNewsBlob;
const seenNews = new Set<string>();
const notifiedNews: string[] = [];
const newsLifecycle = new DraupnirNewsLifecycle(
seenNews,
fileSystemNews,
async (allNews) => {
allNews.forEach((item) => seenNews.add(item.news_id));
return Ok(undefined);
},
async () => ResultError.Result("Can't fetch remote news :("),
async (item) => {
notifiedNews.push(item.news_id);
return Ok(undefined);
}
);
expect(seenNews.size).toBe(0);
expect(notifiedNews.length).toBe(0);
await newsLifecycle.checkForNews();
expect(seenNews.size).toBe(1);
expect(notifiedNews.length).toBe(1);
await newsLifecycle.checkForNews();
expect(notifiedNews.length).toBe(1);
});
});

View File

@@ -319,10 +319,10 @@
"@gnuxie/super-cool-stream" "^0.2.1"
"@gnuxie/typescript-result" "^1.0.0"
"@the-draupnir-project/matrix-basic-types@1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@the-draupnir-project/matrix-basic-types/-/matrix-basic-types-1.4.0.tgz#18fcfc7561ad495f4868ef4298131a3e20e7d946"
integrity sha512-nKK9vmAXh87VwaANvlNlUaq/rIu50VcdRXfoPJB99RqY4dt6iXRu/1b8mQJ5rDCK4yun/4IyGexw6FVQAqT58Q==
"@the-draupnir-project/matrix-basic-types@1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@the-draupnir-project/matrix-basic-types/-/matrix-basic-types-1.4.1.tgz#2e8fdbadb0781fba29383c5cdf530357d25b8718"
integrity sha512-NzA2EWiTQK774J6hZZ4W5xJWY0G9J1gadrTWJfVrAo5GupwfY3nmRtVgZogpqwWbMV0t2zKcqXYuYqyP+1ju5w==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
glob-to-regexp "^0.4.1"
@@ -2606,10 +2606,10 @@ matrix-appservice@^2.0.0:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.13.0":
version "3.13.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.13.0.tgz#cff398d88a1e4230074b4e29671cd088efa85943"
integrity sha512-sgBDvHwR1J/1//Z5grWz2+J46Q2VZpzF4QjlaE2VzBQxLjlQS7d8csM2/QxRpaiLcMUdU+d+5QTqxQT131Bd9g==
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-4.0.0.tgz#86d9522397f79672dd65077c1c4d344bcc63094e"
integrity sha512-NmkqQMgPr3mDyg8KYfSx9Prb/T1RgGG/O2ASXmJzGfEBTfWf9NeXiDeG4VqgIqJkuoxvp8vbk1/nRLcYnMDDuQ==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"