diff --git a/CHANGELOG.md b/CHANGELOG.md index 07943869..7eb64835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## New Features * Implementing the [FEP-1970](https://codeberg.org/fediverse/fep/src/branch/main/fep/1970/fep-1970.md) draft for ActivityPub chat declaration. +* Podcast RSS feed support (thanks to [Alecks Gates](https://github.com/agates)). ## 7.1.0 diff --git a/package-lock.json b/package-lock.json index ee89dee5..839ed707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "validate-color": "^2.2.1" }, "devDependencies": { - "@peertube/peertube-types": "^5.1.0", + "@peertube/feed": "^5.1.0", + "@peertube/peertube-types": "^5.2.0", "@tsconfig/node12": "^1.0.9", "@types/async": "^3.2.9", "@types/express": "^4.17.13", @@ -2557,16 +2558,29 @@ "node": ">=14" } }, - "node_modules/@peertube/peertube-types": { + "node_modules/@peertube/feed": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@peertube/peertube-types/-/peertube-types-5.1.0.tgz", - "integrity": "sha512-n0FMlKzHae/HuBXXeUd5nWUmBN+BMiyRkwftRquFqyQObwTwltqUooL+DBcjetFsRmPTObvF4kPccQ7LTLqkXQ==", + "resolved": "https://registry.npmjs.org/@peertube/feed/-/feed-5.1.0.tgz", + "integrity": "sha512-ggwIbjxh4oc1aAGYV7ZxtIpiEIGq3Rkg6FxvOSrk/EPZ76rExoIJCjKeSyd4zb/sGkyKldy+bGs1OUUVidWWTQ==", + "dev": true, + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@peertube/peertube-types": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@peertube/peertube-types/-/peertube-types-5.2.0.tgz", + "integrity": "sha512-t5o/W4cF+E8FJXvFKBuGuCGU01Ad7jodyO7go//UYzgTde4CpxVJIE4G/8fKB30Kl0GCtqm2gncj31gilxZJ0g==", "dev": true, "dependencies": { "@aws-sdk/client-s3": "^3.190.0", "@node-oauth/oauth2-server": "^4.2.0", "@opentelemetry/api": "^1.1.0", "@opentelemetry/sdk-metrics": "^1.8.0", + "@peertube/feed": "^5.1.0", "@types/bluebird": "^3.5.33", "@types/express": "4.17.9", "@types/fluent-ffmpeg": "^2.1.16", @@ -2581,25 +2595,25 @@ "bullmq": "^3.6.6", "execa": "^5.1.1", "express": "^4.18.1", - "express-validator": "^6.4.0", + "express-validator": "^7.0.1", "fluent-ffmpeg": "^2.1.0", "fs-extra": "^11.1.0", "got": "^11.8.2", "hpagent": "^1.0.0", "ioredis": "^5.2.3", - "lru-cache": "^7.13.0", + "lru-cache": "^9.1.1", "memoizee": "^0.4.14", "multer": "^1.4.5-lts.1", - "parse-torrent": "^9.1.0", + "parse-torrent": "^9", "reflect-metadata": "^0.1.12", - "sequelize": "6.29.0", + "sequelize": "6.31.1", "sequelize-typescript": "^2.0.0-beta.1", "short-uuid": "^4.2.0", "socket.io": "^4.5.4", "winston": "3.8.2" }, "engines": { - "node": ">=12.x", + "node": ">=16.x", "yarn": ">=1.x" } }, @@ -2622,12 +2636,12 @@ "dev": true }, "node_modules/@peertube/peertube-types/node_modules/lru-cache": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz", - "integrity": "sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.2.tgz", + "integrity": "sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==", "dev": true, "engines": { - "node": ">=12" + "node": "14 || >=16.14" } }, "node_modules/@sindresorhus/is": { @@ -5736,13 +5750,13 @@ } }, "node_modules/express-validator": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.0.tgz", - "integrity": "sha512-ZWHJfnRgePp3FKRSKMtnZVnD1s8ZchWD+jSl7UMseGIqhweCo1Z9916/xXBbJAa6PrA3pUZfkOvIsHZG4ZtIMw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.0.1.tgz", + "integrity": "sha512-oB+z9QOzQIE8FnlINqyIFA8eIckahC6qc8KtqLdLJcU3/phVyuhXH3bA4qzcrhme+1RYaCSwrq+TlZ/kAKIARA==", "dev": true, "dependencies": { "lodash": "^4.17.21", - "validator": "^13.7.0" + "validator": "^13.9.0" }, "engines": { "node": ">= 8.0.0" @@ -8804,6 +8818,12 @@ "node": ">=8.9.0" } }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, "node_modules/semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -8844,9 +8864,9 @@ "dev": true }, "node_modules/sequelize": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.29.0.tgz", - "integrity": "sha512-m8Wi90rs3NZP9coXE52c7PL4Q078nwYZXqt1IxPvgki7nOFn0p/F0eKsYDBXCPw9G8/BCEa6zZNk0DQUAT4ypA==", + "version": "6.31.1", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.31.1.tgz", + "integrity": "sha512-cahWtRrYLjqoZP/aurGBoaxn29qQCF4bxkAUPEQ/ozjJjt6mtL4Q113S3N39mQRmX5fgxRbli+bzZARP/N51eg==", "dev": true, "funding": [ { @@ -10276,9 +10296,9 @@ } }, "node_modules/validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", "dev": true, "engines": { "node": ">= 0.10" @@ -10441,6 +10461,18 @@ } } }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12611,16 +12643,26 @@ "integrity": "sha512-hO+bdeGOlJwqowUBoZF5LyP3ORUFOP1G0GRv8N45W/cztXbT2ZEXaAzfokRS9Xc9FWmYrDj32mF6SzH6wuoIyA==", "dev": true }, - "@peertube/peertube-types": { + "@peertube/feed": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@peertube/peertube-types/-/peertube-types-5.1.0.tgz", - "integrity": "sha512-n0FMlKzHae/HuBXXeUd5nWUmBN+BMiyRkwftRquFqyQObwTwltqUooL+DBcjetFsRmPTObvF4kPccQ7LTLqkXQ==", + "resolved": "https://registry.npmjs.org/@peertube/feed/-/feed-5.1.0.tgz", + "integrity": "sha512-ggwIbjxh4oc1aAGYV7ZxtIpiEIGq3Rkg6FxvOSrk/EPZ76rExoIJCjKeSyd4zb/sGkyKldy+bGs1OUUVidWWTQ==", + "dev": true, + "requires": { + "xml-js": "^1.6.11" + } + }, + "@peertube/peertube-types": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@peertube/peertube-types/-/peertube-types-5.2.0.tgz", + "integrity": "sha512-t5o/W4cF+E8FJXvFKBuGuCGU01Ad7jodyO7go//UYzgTde4CpxVJIE4G/8fKB30Kl0GCtqm2gncj31gilxZJ0g==", "dev": true, "requires": { "@aws-sdk/client-s3": "^3.190.0", "@node-oauth/oauth2-server": "^4.2.0", "@opentelemetry/api": "^1.1.0", "@opentelemetry/sdk-metrics": "^1.8.0", + "@peertube/feed": "^5.1.0", "@types/bluebird": "^3.5.33", "@types/express": "4.17.9", "@types/fluent-ffmpeg": "^2.1.16", @@ -12635,18 +12677,18 @@ "bullmq": "^3.6.6", "execa": "^5.1.1", "express": "^4.18.1", - "express-validator": "^6.4.0", + "express-validator": "^7.0.1", "fluent-ffmpeg": "^2.1.0", "fs-extra": "^11.1.0", "got": "^11.8.2", "hpagent": "^1.0.0", "ioredis": "^5.2.3", - "lru-cache": "^7.13.0", + "lru-cache": "^9.1.1", "memoizee": "^0.4.14", "multer": "^1.4.5-lts.1", - "parse-torrent": "^9.1.0", + "parse-torrent": "^9", "reflect-metadata": "^0.1.12", - "sequelize": "6.29.0", + "sequelize": "6.31.1", "sequelize-typescript": "^2.0.0-beta.1", "short-uuid": "^4.2.0", "socket.io": "^4.5.4", @@ -12672,9 +12714,9 @@ "dev": true }, "lru-cache": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz", - "integrity": "sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.2.tgz", + "integrity": "sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==", "dev": true } } @@ -15053,13 +15095,13 @@ } }, "express-validator": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.0.tgz", - "integrity": "sha512-ZWHJfnRgePp3FKRSKMtnZVnD1s8ZchWD+jSl7UMseGIqhweCo1Z9916/xXBbJAa6PrA3pUZfkOvIsHZG4ZtIMw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.0.1.tgz", + "integrity": "sha512-oB+z9QOzQIE8FnlINqyIFA8eIckahC6qc8KtqLdLJcU3/phVyuhXH3bA4qzcrhme+1RYaCSwrq+TlZ/kAKIARA==", "dev": true, "requires": { "lodash": "^4.17.21", - "validator": "^13.7.0" + "validator": "^13.9.0" } }, "ext": { @@ -17285,6 +17327,12 @@ "chokidar": ">=3.0.0 <4.0.0" } }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -17321,9 +17369,9 @@ } }, "sequelize": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.29.0.tgz", - "integrity": "sha512-m8Wi90rs3NZP9coXE52c7PL4Q078nwYZXqt1IxPvgki7nOFn0p/F0eKsYDBXCPw9G8/BCEa6zZNk0DQUAT4ypA==", + "version": "6.31.1", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.31.1.tgz", + "integrity": "sha512-cahWtRrYLjqoZP/aurGBoaxn29qQCF4bxkAUPEQ/ozjJjt6mtL4Q113S3N39mQRmX5fgxRbli+bzZARP/N51eg==", "dev": true, "requires": { "@types/debug": "^4.1.7", @@ -18403,9 +18451,9 @@ } }, "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", "dev": true }, "vary": { @@ -18531,6 +18579,15 @@ "dev": true, "requires": {} }, + "xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "requires": { + "sax": "^1.2.4" + } + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index c65b7ea9..2cd2ec19 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "validate-color": "^2.2.1" }, "devDependencies": { - "@peertube/peertube-types": "^5.1.0", + "@peertube/feed": "^5.1.0", + "@peertube/peertube-types": "^5.2.0", "@tsconfig/node12": "^1.0.9", "@types/async": "^3.2.9", "@types/express": "^4.17.13", diff --git a/server/lib/rss/init.ts b/server/lib/rss/init.ts new file mode 100644 index 00000000..037b3fe1 --- /dev/null +++ b/server/lib/rss/init.ts @@ -0,0 +1,87 @@ +import type { RegisterServerOptions, Video } from '@peertube/peertube-types' +import type { CustomTag } from '@peertube/feed/lib/typings' +import { videoHasWebchat } from '../../../shared/lib/video' +import { fillVideoCustomFields } from '../custom-fields' +import { getProsodyDomain } from '../prosody/config/domain' +import { getPublicChatUri } from '../uri/webchat' + +async function initRSS (options: RegisterServerOptions): Promise { + const logger = options.peertubeHelpers.logger + const registerHook = options.registerHook + logger.info('Registring RSS hooks...') + + registerHook({ + target: 'filter:feed.podcast.video.create-custom-tags.result', + handler: async ( + result: CustomTag[], { video, liveItem }: { video: Video, liveItem: boolean } + ): Promise => { + if (!liveItem) { + // Note: the Podcast RSS feed specification does not handle chats for non-live. + // So we just return here. + return result + } + + // FIXME: calling getSettings for each RSS entry is not optimal. + // Settings should be cached somewhere on the plugin level. + // (i already have some plans to do something for this) + const settings = await options.settingsManager.getSettings([ + 'chat-per-live-video', + 'chat-all-lives', + 'chat-all-non-lives', + 'chat-videos-list', + 'prosody-room-type', + 'federation-dont-publish-remotely', + 'prosody-room-allow-s2s', + 'prosody-s2s-port' + ]) + + if (settings['federation-dont-publish-remotely']) { + // Chat must not be published to the outer world. + return result + } + + await fillVideoCustomFields(options, video) + const hasChat = await videoHasWebchat({ + 'chat-per-live-video': !!settings['chat-per-live-video'], + 'chat-all-lives': !!settings['chat-all-lives'], + 'chat-all-non-lives': !!settings['chat-all-non-lives'], + 'chat-videos-list': settings['chat-videos-list'] as string + }, video) + + if (!hasChat) { + logger.debug(`Video uuid=${video.uuid} has not livechat, no need to add podcast:chat tag.`) + return result + } + + const prosodyDomain = await getProsodyDomain(options) + const podcastChat: any = { + name: 'podcast:chat', + attributes: { + server: prosodyDomain, + protocol: 'xmpp', + // space: will be added only if external XMPP connections are available + embedUrl: getPublicChatUri(options, video) + } + } + + // In order to connect to the chat using standard xmpp, it requires these settings: + // - prosody-room-allow-s2s + // - prosody-s2s-port + if (settings['prosody-room-allow-s2s'] && settings['prosody-s2s-port']) { + let roomJID: string + if (settings['prosody-room-type'] === 'channel') { + roomJID = `channel.${video.channel.id}@room.${prosodyDomain}` + } else { + roomJID = `${video.uuid}@room.${prosodyDomain}` + } + podcastChat.attributes.space = roomJID + } + + return result.concat([podcastChat]) + } + }) +} + +export { + initRSS +} diff --git a/server/lib/uri/webchat.ts b/server/lib/uri/webchat.ts index 7121bc1a..6aebe66e 100644 --- a/server/lib/uri/webchat.ts +++ b/server/lib/uri/webchat.ts @@ -1,5 +1,4 @@ - -import type { RegisterServerOptions, VideoObject } from '@peertube/peertube-types' +import type { RegisterServerOptions, VideoObject, Video } from '@peertube/peertube-types' import { getBaseRouterRoute, getBaseWebSocketRoute } from '../helpers' import { canonicalizePluginUri } from './canonicalize' @@ -19,7 +18,7 @@ export function getWSS2SUri (options: RegisterServerOptions): string | undefined return base + 'xmpp-websocket-s2s' } -export function getPublicChatUri (options: RegisterServerOptions, video: VideoObject): string { +export function getPublicChatUri (options: RegisterServerOptions, video: VideoObject | Video): string { const url = getBaseRouterRoute(options) + 'webchat/room/' + encodeURIComponent(video.uuid) return canonicalizePluginUri(options, url, { removePluginVersion: true diff --git a/server/main.ts b/server/main.ts index beb1e95d..c044cfc7 100644 --- a/server/main.ts +++ b/server/main.ts @@ -4,6 +4,7 @@ import { initSettings } from './lib/settings' import { initCustomFields } from './lib/custom-fields' import { initRouters } from './lib/routers/index' import { initFederation } from './lib/federation/init' +import { initRSS } from './lib/rss/init' import { prepareProsody, ensureProsodyRunning, ensureProsodyNotRunning } from './lib/prosody/ctl' import { unloadDebugMode } from './lib/debug' import { loadLoc } from './lib/loc' @@ -30,6 +31,7 @@ async function register (options: RegisterServerOptions): Promise { await initCustomFields(options) await initRouters(options) await initFederation(options) + await initRSS(options) try { await prepareProsody(options) diff --git a/support/documentation/content/technical/thirdparty/_index.en.md b/support/documentation/content/technical/thirdparty/_index.en.md index 82fdaa41..2b6bcc09 100644 --- a/support/documentation/content/technical/thirdparty/_index.en.md +++ b/support/documentation/content/technical/thirdparty/_index.en.md @@ -141,7 +141,7 @@ If you want to display the chat in a web page or in an iframe, here is what you * get the Video ActivityPub object, * if there is no `attachment` key, stop. -* loop through the `attachment` values (if `attachment is not an array, just iterate on this single value) +* loop through the `attachment` values (if `attachment` is not an array, just iterate on this single value) * search for an entry with `rel` === `discussion`, and with `href` using the `https` scheme (that begins with `https://`) * if found, open this href @@ -149,7 +149,7 @@ If you want to open the chat room using the XMPP protocol: * get the Video ActivityPub object, * if there is no `attachment` key, stop. -* loop through the `attachment` values (if `attachment is not an array, just iterate on this single value) +* loop through the `attachment` values (if `attachment` is not an array, just iterate on this single value) * search for an entry with `rel` === `discussion`, and with `href` using the `xmpp` scheme (that begins with `xmpp://`) * if found, open this xmpp uri with your client, or connect to the XMPP room at that address @@ -159,7 +159,9 @@ In the ActivityPub object, there is also a `peertubeLiveChat` entry. Don't use the content of this entry. This is specific to the livechat plugin, and can be changed or removed in a near future. It is currently required for some endpoint discovery. -### Using RSS +### Using Podcast RSS feed + +The livechat plugin adds some data in Podcast RSS feeds under the [``](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#live-item), so that the chat can be discovered for live streams. {{% notice warning %}} This part is not implemented yet, but should be available for v7.2.0 release. @@ -168,3 +170,94 @@ This part is not implemented yet, but should be available for v7.2.0 release. {{% notice info %}} This requires Peertube >= 5.2 {{% /notice %}} + +{{% notice info %}} +The `` element is currently only supported for live streams. +{{% /notice %}} + +This follows the [``](https://github.com/Podcastindex-org/podcast-namespace/discussions/502) proposal. + +{{% notice warning %}} +At the time of the writing, this proposal is in draft status, and the livechat plugin is a Proof-of-concept. +Until the proposal is adopted, the specification can change, and the livechat plugin will be adapted accordingly. +{{% /notice %}} + +Basically, the chat will be declared as tag under on the `` element. + +By default, here is an example of what you will get: + +```xml + + The video title + e32b4890-983b-4ce5-8b46-f2d6bc1d8819_2023-07-06T18:00:00.000Z + https://yourinstance.tld/videos/watch/8df24108-6e70-4fc8-b1cc-f2db7fcdd535 + + + + + + + + +``` + +In case the instance has activated the + [external XMPP clients connection](/peertube-plugin-livechat/documentation/admin/advanced/xmpp_clients/) feature: + +```xml + + The video title + e32b4890-983b-4ce5-8b46-f2d6bc1d8819_2023-07-06T18:00:00.000Z + https://yourinstance.tld/videos/watch/8df24108-6e70-4fc8-b1cc-f2db7fcdd535 + + + + + + + + +``` + +#### Algorithm + +If you want to display the chat in a web page or in an iframe, here is what you should do: + +* get the Podcast RSS feed for the channel, +* if there is no `` element under the ``, stop. +* find the `` you are looking for + * `` can be used to cross-reference the items with ActivityPub +* if there is no `` element under the ``, stop. +* loop through the `` values (if `` is not an array, just iterate on this single value) + * there should only be one, but you should expect to handle several just in case +* search for the first entry `protocol` === `xmpp` and an `embedUrl` attribute +* if found, open this embedUrl + +If you want to open the chat room using the XMPP protocol: + +* get the Podcast RSS feed for the channel, +* if there is no `` element under the ``, stop. +* find the `` you are looking for + * `` can be used to cross-reference the items with ActivityPub +* loop through the `` values (if `` is not an array, just iterate on this single value) + * there should only be one, but you should expect to handle several just in case +* search for the first entry `protocol` === `xmpp` and a `space` attribute + * space should be an XMPP JID for a MUC +* if found, open this XMPP JID with your client after converting it to a join URI, or connect to the XMPP room at that address