diff --git a/config/harness.yaml b/config/harness.yaml index 139837f..2eb2690 100644 --- a/config/harness.yaml +++ b/config/harness.yaml @@ -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 diff --git a/mx-tester.yml b/mx-tester.yml index 16b9abe..e417ccf 100644 --- a/mx-tester.yml +++ b/mx-tester.yml @@ -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 diff --git a/package.json b/package.json index 7126db6..34a72c3 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/config.ts b/src/config.ts index 79b6ee0..a33d169 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, }; diff --git a/src/protections/DraupnirNews/DraupnirNews.tsx b/src/protections/DraupnirNews/DraupnirNews.tsx new file mode 100644 index 0000000..1071dab --- /dev/null +++ b/src/protections/DraupnirNews/DraupnirNews.tsx @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// 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; +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; +export const DraupnirNewsBlob = Type.Object({ + news: Type.Array(DraupnirNewsItem), +}); + +export const DraupnirNewsHelper = Object.freeze({ + mergeSources(...blobs: DraupnirNewsBlob[]): DraupnirNewsItem[] { + return [ + ...new Map( + blobs + .reduce((acc, blob) => [...acc, ...blob.news], []) + .map((item) => [item.news_id, item]) + ).values(), + ]; + }, + removeSeenNews(news: DraupnirNewsItem[], seenNewsIDs: Set) { + return news.filter((item) => !seenNewsIDs.has(item.news_id)); + }, + removeUnseenNews(news: DraupnirNewsItem[], seenNewsIDs: Set) { + return news.filter((item) => seenNewsIDs.has(item.news_id)); + }, +}); + +export type StoreSeenNews = ( + seenNews: DraupnirNewsItem[] +) => Promise>; +export type FetchRemoteNews = () => Promise>; +export type NotifyNewsItem = (item: DraupnirNewsItem) => Promise>; + +/** + * 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, + private readonly localNews: DraupnirNewsBlob, + private readonly storeNews: StoreSeenNews, + private readonly fetchRemoteNews: FetchRemoteNews, + private readonly notifyNewsItem: NotifyNewsItem + ) { + // nothing to do. + } + + public async checkForNews(): Promise { + 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> { + 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 { + 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; +export type DraupnirNewsProtectionSettings = EDStatic< + typeof DraupnirNewsProtectionSettings +>; + +export type DraupnirNewsDescription = ProtectionDescription< + Draupnir, + typeof DraupnirNewsProtectionSettings, + DraupnirNewsProtectionCapabilities +>; + +export class DraupnirNews + extends AbstractProtection + implements DraupnirProtection +{ + 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> + ), + 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> { + 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 + ) + ); + }, +}); diff --git a/src/protections/DraupnirNews/news.json b/src/protections/DraupnirNews/news.json new file mode 100644 index 0000000..9912cab --- /dev/null +++ b/src/protections/DraupnirNews/news.json @@ -0,0 +1,3 @@ +{ + "news": [] +} diff --git a/src/protections/DraupnirNews/news.json.license b/src/protections/DraupnirNews/news.json.license new file mode 100644 index 0000000..f4ceed1 --- /dev/null +++ b/src/protections/DraupnirNews/news.json.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Gnuxie +# +# SPDX-License-Identifier: CC0-1.0 diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts index 0a06249..a51f82d 100644 --- a/src/protections/DraupnirProtectionsIndex.ts +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -14,6 +14,7 @@ import "../capabilities/capabilityIndex"; // keep alphabetical please. import "./BanPropagation"; import "./BasicFlooding"; +import "./DraupnirNews/DraupnirNews"; import "./FirstMessageIsImage"; import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection"; import "./InvalidEventProtection"; diff --git a/src/protections/invitation/ProtectRoomsOnInvite.tsx b/src/protections/invitation/ProtectRoomsOnInvite.tsx index cbfcf1e..1ee9a3a 100644 --- a/src/protections/invitation/ProtectRoomsOnInvite.tsx +++ b/src/protections/invitation/ProtectRoomsOnInvite.tsx @@ -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. diff --git a/src/protections/invitation/WatchRoomsOnInvite.tsx b/src/protections/invitation/WatchRoomsOnInvite.tsx index 435b405..39b3f24 100644 --- a/src/protections/invitation/WatchRoomsOnInvite.tsx +++ b/src/protections/invitation/WatchRoomsOnInvite.tsx @@ -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. diff --git a/test/draupnir_news.json b/test/draupnir_news.json new file mode 100644 index 0000000..5d14c6b --- /dev/null +++ b/test/draupnir_news.json @@ -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" + } + } + ] +} diff --git a/test/draupnir_news.json.license b/test/draupnir_news.json.license new file mode 100644 index 0000000..f4ceed1 --- /dev/null +++ b/test/draupnir_news.json.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Gnuxie +# +# SPDX-License-Identifier: CC0-1.0 diff --git a/test/nginx.conf b/test/nginx.conf index 98105c3..f51583c 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -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; + } } } diff --git a/test/unit/protections/DraupnirNewsTest.ts b/test/unit/protections/DraupnirNewsTest.ts new file mode 100644 index 0000000..f15db5d --- /dev/null +++ b/test/unit/protections/DraupnirNewsTest.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// 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(); + 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(); + 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); + }); +}); diff --git a/yarn.lock b/yarn.lock index a48665a..03def2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"