├── .github └── ISSUE_TEMPLATE │ └── new-agency.md ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── activity.html ├── actor.html └── index.html ├── services.schema.json ├── src ├── alerts │ ├── fetchFeed.ts │ ├── generateActivityForAlert.ts │ └── makeRefreshJobs.ts ├── index.ts ├── models │ ├── alert.ts │ └── config.ts ├── plugins │ ├── cta │ │ └── index.ts │ └── gtfsrt │ │ └── index.ts ├── typings │ └── activitypub-express │ │ └── index.d.ts └── util │ ├── parseConfigFile.ts │ └── syncServices.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/new-agency.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Agency 3 | about: For requesting an new agency in the official instance 4 | title: Add 5 | labels: new-agency 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Agency name:** 11 | **City/region/other locality:** 12 | **Link to alerts feed docs/info (if known):** 13 | 14 | **Other comments/discussion:** 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .env 3 | .DS_Store 4 | node_modules/ 5 | dist/ 6 | services.json 7 | selfsigned.crt 8 | selfsigned.key 9 | public/files/** -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Transit Fedilerts 2 | This project distributes transit alerts via ActivityPub, mainly sourcing from GTFS-realtime Service Alerts feeds. It's primarily built on top of the [`activitypub-express`](https://github.com/immers-space/activitypub-express) library. 3 | 4 | Currently, Transit Fedilerts is in beta. There may be bugs and features are limited. I welcome issues and pull requests! 5 | 6 | The "official" instance lives at [transit.alerts.social](https://transit.alerts.social) 7 | 8 | 9 | # Usage 10 | - Clone the `transit-fedilerts` repo and install depdencies 11 | - Define the agencies/services/feeds to include in `services.json` 12 | - The structure of this file is documented in the JSON schema format at `services.schema.json` (example below) 13 | - While the system looks for `services.json` by default, you can define a custom path with the environment variable `SERVICES_JSON` 14 | - Compile TypeScript and run 15 | 16 | ## HTTPS/SSL 17 | Transit Fedilerts starts up as an HTTP server, but ActivityPub requires HTTPS. A reverse proxy, such as nginx, is recommended to provide SSL support. Setting `DOMAIN` to `localhost` will introduce problems; for local development, consider [ngrok](https://ngrok.com) or other options that provide temporary domain names with SSL. 18 | 19 | 20 | ## Environment Variables 21 | Transit Fedilerts uses `dotenv` for environment variables. 22 | | Name | Required? | Description | 23 | |------|-----------|-------------| 24 | | `DOMAIN` | X | Domain name for the server | 25 | | `MONGO_DB_NAME` | | The name of the MongoDB db to use. Defaults to `transitFedilerts` | 26 | | `MONGO_URI` | | The connection URI for the Mongo instance. Defaults to `mongodb://localhost:27017` | 27 | | `NO_FETCH_ALERTS` | | When present, the alert fetchers won't run. Useful for testing other components | 28 | | `PORT` | | Port to run the server on. Defaults to `8080` | 29 | | `SERVICES_JSON` | | Custom path to a services configuration file. Defaults to `services.json` | 30 | 31 | 32 | ## Example `services.json` 33 | The config file is intended to be flexible and handle multiple use cases. A service is defined as a single transit entity and translates into an account users can follow, and a feed as the alerts feed itself. This separation will allow for complex use cases, such as agencies whose alerts might be in multiple feeds or feeds which may contain alerts from multiple agencies. 34 | 35 | Here's a simple implementation for a single agency with a single feed: 36 | ``` 37 | { 38 | "services": [ 39 | { 40 | "identifier": "commtrans", 41 | "name": "Community Transit", 42 | "iconUrl": "/commtrans.jpg" 43 | } 44 | ], 45 | "feeds": [ 46 | { 47 | "url": "https://s3.amazonaws.com/commtrans-realtime-prod/alerts.pb", 48 | "relatesTo": ["commtrans"] 49 | } 50 | ] 51 | } 52 | ``` 53 | Included in CT's feed are alerts for several Sound Transit routes. Perhaps we want to create a separate Sound Transit feed that includes just those routes: 54 | ``` 55 | { 56 | "services": [ 57 | { 58 | "identifier": "commtrans", 59 | "name": "Community Transit", 60 | "iconUrl": "/commtrans1.jpg" 61 | }, 62 | { 63 | "identifier": "soundtransit", 64 | "name": "Sound Transit (CT)", 65 | "iconUrl": "/soundtransit-ct1.jpg" 66 | } 67 | ], 68 | "feeds": [ 69 | { 70 | "url": "https://s3.amazonaws.com/commtrans-realtime-prod/alerts.pb", 71 | "relatesTo": [ 72 | "commtrans", 73 | { 74 | "identifier": "soundtransit", 75 | "criteria": [ 76 | { 77 | "routeId": { 78 | "test": "^5\\d\\d$" 79 | } 80 | } 81 | ] 82 | } 83 | ] 84 | } 85 | ] 86 | } 87 | ``` 88 | This will keep all CT-operated service in `commtrans` and also push anything on an ST route to `soundtransit`. We could also add feeds for King County Metro and Pierce Transit (the other operators of ST Express buses) and push those to `soundtransit` based on similar criteria, optionally with additional services for each of them. 89 | 90 | 91 | ## Non-GTFS-realtime Alert Feeds 92 | The goal of this project is primarily to support GTFS-realtime Service Alerts, but some non-standard formats are supported. Some implementation details and contribution guidelines: 93 | - Each individual plugin exists as a subfolder in `./plugins` with the plugin ID as the folder name 94 | - The plugin ID must be defined as literals in `services.schema.json` and `./models/config.ts` 95 | - The plugin case must be handled in `./alerts/fetchFeed.ts`, which returns an array of every found service alert 96 | - The subfolder should include an `index.ts` file with the parse method as the default export 97 | 98 | 99 | # Roadmap 100 | The following features are not supported but are on my radar for the future—pull requests that start on these are encouraged. They are in no order. 101 | - Web interface for prior alerts 102 | - Currently only show a (very basic) list of services 103 | - Create actors for individual routes 104 | - This one's a big lift: Have to ingest and monitor the GTFS-static feed, map route IDs to names, map stops to routes, etc., as well as send a whole lot of messages per alert. 105 | - Improved Mastodon interopability 106 | - Implement profile metadata: Official agency URLs? Link to server hosting the instance? More might fit here 107 | - Implement certain API endpoints, like [account statuses](https://docs.joinmastodon.org/methods/accounts/#statuses) and the [public timeline](https://docs.joinmastodon.org/methods/timelines/#public) 108 | - Improved reverse proxy support 109 | - The `host` must be maintained when passed from the proxy -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transit-fedilerts", 3 | "version": "0.1.0", 4 | "description": "GTFS-realtime alerts distributed via ActivityPub", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "node dist/index.js", 8 | "build": "tsc", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "Kona Farry", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@types/express": "^4.17.17", 15 | "@types/luxon": "^3.2.0", 16 | "ts-node": "^10.9.1", 17 | "typescript": "^4.9.5" 18 | }, 19 | "dependencies": { 20 | "activitypub-express": "^4.2.2", 21 | "dotenv": "^16.0.3", 22 | "express": "^4.18.2", 23 | "fs": "^0.0.1-security", 24 | "gtfs-realtime-bindings": "^1.1.1", 25 | "luxon": "^3.2.1", 26 | "mongodb": "^5.0.1", 27 | "toad-scheduler": "^2.2.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/activity.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Loading... 5 | 6 | 7 | 8 | 9 |
Loading...
10 |
11 |
12 |
13 | 20 |
21 |
22 |
23 |
24 |
Loading post...
25 |
26 |
27 |
28 |
29 |

Developed by Kona Farry in Everett, Washington. Follow me on Mastodon

30 |
31 |
32 |
33 | 34 | 81 | -------------------------------------------------------------------------------- /public/actor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Loading... 5 | 6 | 7 | 8 | 9 |
Loading...
10 |
11 |
12 |
13 | 19 |
20 |
21 |
22 |
23 |

24 |

25 | 26 |
27 |
28 |
29 |
30 |
Loading posts...
31 |
32 |
33 |
34 |
35 |

Developed by Kona Farry in Everett, Washington. Follow me on Mastodon

36 |
37 |
38 |
39 | 40 | 97 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Transit Fedilerts 5 | 6 | 7 | 8 | 9 |
10 |
11 |
12 |

Transit Fedilerts

13 |

14 | ActivityPub-compliant (Mastodon-compatible) transit alerts generated from GTFS-realtime Service Alerts 15 |

16 |

17 | Source code and more info on GitHub 18 |

19 |
20 |
21 |
22 |
23 |

What is this? How does it work?

24 |

25 | Transit Fedilerts fetches service alerts from transit agencies and publishes them via ActivityPub, essentially creating a series of bot accounts for alerts for various transit services. I maintain an "official" instance at transit.alerts.social, but this open-source software can be deployed by anyone. 26 |

27 |

28 | The service leverages the GTFS-realtime data standard for retrieving transit alerts. 29 |

30 |
31 |
32 |
33 |
34 |

Transit Services Available

35 |

The following transit services are available at this instance and can be followed in your ActivityPub or Mastodon app of choice. Remember to use the format username@this domain

36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
ServiceUsernameLifetime alerts seenNotes
49 |
50 |
51 |
52 |
53 |

Developed by Kona Farry in Everett, Washington. Follow me on Mastodon

54 |
55 |
56 |
57 | 58 | 59 | 60 | 84 | -------------------------------------------------------------------------------- /services.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "definitions": { 4 | "service": { 5 | "properties": { 6 | "identifier": { 7 | "type": "string", 8 | "description": "Stable identifier for the feed. This must be unique across all defined feeds. Will become the 'username' of the account, so should be descriptive to users." 9 | }, 10 | "name": { 11 | "type": "string", 12 | "description": "The name of the included transit service. Will typically be an agency name." 13 | }, 14 | "displayName": { 15 | "type": "string", 16 | "description": "A fitting display name for the feed. Will be shown to users as the account name. If not provided, will default to `{serviceName} Alerts`." 17 | }, 18 | "iconUrl": { 19 | "type": "string", 20 | "description": "The URL for an icon (profile picture) to use for this service. To specify an image hosted on this server, place it in the `public/files` subfolder and populate this property with `/{filename}`." 21 | }, 22 | "summaryNote": { 23 | "type": "string", 24 | "description": "Text to append to the end of the summary/bio text. It will always start with \"Automated service alerts for `{name}`\", but a period and space will be added if this value is defined, i.e. \"Automated service alerts for Community Transit. `{note}`" 25 | } 26 | }, 27 | "required": ["identifier", "name"] 28 | }, 29 | "feed": { 30 | "description": "A place a feed can be found and any related services", 31 | "properties": { 32 | "url": { 33 | "type": "string", 34 | "description": "The URL of the alerts feed." 35 | }, 36 | "headers": { 37 | "type": "object", 38 | "description": "Any headers to provide with each request. Some feeds require `User-Agent` or `X-Api-Key`, for instance." 39 | }, 40 | "type": { 41 | "type": "string", 42 | "description": "The structure of the feed, which defines the plugin to use to parse the data found at `url`. Defaults to `gtfsrt` if undefined.", 43 | "enum": ["gtfsrt", "cta"] 44 | }, 45 | "pollInterval": { 46 | "type": "number", 47 | "description": "Time, in minutes, between each each request to this URL. Defaults to `5` if undefined.", 48 | "default": 5 49 | }, 50 | "relatesTo": { 51 | "type": "array", 52 | "description": "All services this feed should populate. A service defined as just the service ID will receive all alerts from this feed. If defined as an object with a valid `criteria` property, only alerts that match the criteria will be sent to that service.", 53 | "items": { 54 | "anyOf": [ 55 | { 56 | "type": "string" 57 | }, 58 | { 59 | "$ref": "#/definitions/feedRelationToService" 60 | } 61 | ] 62 | } 63 | } 64 | }, 65 | "required": ["url", "relatesTo"] 66 | }, 67 | "feedRelationToService": { 68 | "properties": { 69 | "identifier": { 70 | "type": "string", 71 | "description": "The identifier of the related service" 72 | }, 73 | "criteria": { 74 | "type": "array", 75 | "description": "Criteria for an alert to be considered as related to this feed. Any criterion in this array being met will result in a match.", 76 | "items": { 77 | "$ref": "#/definitions/feedServiceRelationshipCritera" 78 | } 79 | } 80 | }, 81 | "required": ["identifier"] 82 | }, 83 | "feedServiceRelationshipCritera": { 84 | "properties": { 85 | "agencyId": { 86 | "$ref": "#/definitions/criteriaValue", 87 | "description": "An agency ID in the alert's informed entities" 88 | }, 89 | "routeId": { 90 | "$ref": "#/definitions/criteriaValue", 91 | "description": "A route ID in the alert's informed entities" 92 | }, 93 | "routeType": { 94 | "$ref": "#/definitions/criteriaValue", 95 | "description": "A route type in the alert's informed entities" 96 | }, 97 | "directionId": { 98 | "$ref": "#/definitions/criteriaValue", 99 | "description": "A direction ID in the alert's informed entities. In GTFS-rt spec, must be used with a `routeId`" 100 | }, 101 | "stopId": { 102 | "$ref": "#/definitions/criteriaValue", 103 | "description": "A stop ID in the alert's informed entities" 104 | } 105 | } 106 | }, 107 | "criteriaValue": { 108 | "anyOf": [ 109 | { 110 | "$ref": "#/definitions/criteriaItem" 111 | }, 112 | { 113 | "type": "array", 114 | "items": { 115 | "$ref": "#/definitions/criteriaItem" 116 | } 117 | } 118 | ] 119 | }, 120 | "criteriaItem": { 121 | "anyOf": [ 122 | { 123 | "description": "A literal value to match exactly", 124 | "type": "string" 125 | }, 126 | { 127 | "description": "A regular expression to test against", 128 | "properties": { 129 | "test": { 130 | "type": "string" 131 | }, 132 | "flags": { 133 | "type": "string" 134 | } 135 | }, 136 | "required": ["test"] 137 | } 138 | ] 139 | } 140 | }, 141 | "properties": { 142 | "services": { 143 | "type": "array", 144 | "items": { 145 | "$ref": "#/definitions/service" 146 | } 147 | }, 148 | "feeds": { 149 | "type": "array", 150 | "items": { 151 | "$ref": "#/definitions/feed" 152 | } 153 | } 154 | }, 155 | "required": ["services", "feeds"] 156 | } -------------------------------------------------------------------------------- /src/alerts/fetchFeed.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from "../models/config" 2 | import getAlertsFromGtfsRt from "../plugins/gtfsrt" 3 | import getAlertsFromCta from "../plugins/cta" 4 | 5 | export default async function fetchFeed(feed: Feed) { 6 | if (feed.relatesTo.length == 0) { 7 | //if feed has no service to talk to, no need to fetch 8 | return [] 9 | } 10 | const feedType = feed.type ?? "gtfsrt" 11 | if (feedType == "gtfsrt") { 12 | const res = await getAlertsFromGtfsRt(feed) 13 | return res 14 | } else if (feedType == "cta") { 15 | const res = await getAlertsFromCta(feed) 16 | return res 17 | } 18 | throw new Error(`invalid feed type "${feed.type}" defined for ${feed.url}`) 19 | } -------------------------------------------------------------------------------- /src/alerts/generateActivityForAlert.ts: -------------------------------------------------------------------------------- 1 | import { ActivityPubExpress } from 'activitypub-express' 2 | import { TransitAlert } from '../models/alert' 3 | 4 | /** 5 | * Returns an object of type `Note` for the given alert and an activity of type `Create` for the Note. 6 | */ 7 | export default async function generateActivityForAlert(alert: TransitAlert, actor: string, apex: ActivityPubExpress) { 8 | const alertContent = [alert.header, alert.body].filter(t => t).join("

").replace(/(?:\r\n|\r|\n)/g, "
") 9 | const start = alert.date 10 | const now = new Date() 11 | const alertDate = start ?? now 12 | //for simplicity, we're going to flatten down future-dated alerts to now 13 | //TODO: Find a better way to represent future-dated alerts, maybe this: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-published 14 | const postDate = alertDate > now ? now : alertDate 15 | const actorId = apex.utils.usernameToIRI(actor) 16 | const followersAt = actorId + "/followers" 17 | const note = buildPublicNote(apex, alertContent, actorId, postDate, followersAt) 18 | const activity = await apex.buildActivity('Create', actorId, "https://www.w3.org/ns/activitystreams#Public", { 19 | object: note, 20 | cc: followersAt, //followers uri 21 | }) 22 | return activity 23 | } 24 | 25 | function buildPublicNote(apex: ActivityPubExpress, content: string, actorId: string, published: Date, cc: string) { 26 | const id : string = apex.store.generateId() 27 | return { 28 | id: apex.utils.objectIdToIRI(id), 29 | type: "Note", 30 | content, 31 | attributedTo: actorId, 32 | published: published.toISOString(), 33 | to: "https://www.w3.org/ns/activitystreams#Public", 34 | cc, 35 | } 36 | } -------------------------------------------------------------------------------- /src/alerts/makeRefreshJobs.ts: -------------------------------------------------------------------------------- 1 | import { ActivityPubExpress } from "activitypub-express" 2 | import { MongoClient } from "mongodb" 3 | import { SimpleIntervalJob, AsyncTask } from "toad-scheduler" 4 | import { TransitAlert } from "../models/alert" 5 | import { Feed, FeedRelationToService } from "../models/config" 6 | import fetchAlerts from "./fetchFeed" 7 | import generateActivityForAlert from "./generateActivityForAlert" 8 | 9 | export default async function makeRefreshJobs(feeds: Feed[], apex: ActivityPubExpress, client: MongoClient) : Promise { 10 | const db = client.db(process.env.MONGO_DB_NAME ?? 'transitFedilerts') 11 | const collection = db.collection('transitSeenAlerts') 12 | const index = [ 'service', 'feedUrl', 'alertId' ] 13 | await collection.createIndex(index) 14 | 15 | return feeds 16 | .map(feed => { 17 | const task = new AsyncTask('service-'+feed.url+'-fetch', async () => { 18 | console.log('fetching updates') 19 | const alerts = await fetchAlerts(feed) 20 | return alerts.flatMap(async (alert) => { 21 | feed.relatesTo.map(async (service) => { 22 | //ignore non-alerts or unrelated alerts 23 | if (!alertRelates(alert, service)) { 24 | return Promise.resolve() 25 | } 26 | const serviceId = typeof service == "string" ? service : service.identifier 27 | const alertInfo = {service: serviceId, url: feed.url, alertId: alert.id} 28 | const exists = await collection.findOne(alertInfo) 29 | if (exists) { 30 | return Promise.resolve() 31 | } 32 | return generateActivityForAlert(alert, serviceId, apex) 33 | .then(async (activity) => { 34 | const actor = await apex.store.getObject(activity.actor[0], true) 35 | await Promise.all([ 36 | apex.store.saveObject(activity.object[0]), 37 | apex.addToOutbox(actor, activity), 38 | ]) 39 | await collection.insertOne(alertInfo) 40 | console.log(activity) 41 | }) 42 | }) 43 | }) 44 | }) 45 | return new SimpleIntervalJob({minutes: feed.pollInterval ?? 5, runImmediately: true}, task, {preventOverrun: true}) 46 | }) 47 | } 48 | 49 | function alertRelates(alert: TransitAlert, relation: FeedRelationToService) { 50 | if (typeof relation == "string") { 51 | return true 52 | } else if (relation.criteria?.length == 0 ?? true) { 53 | return true 54 | } 55 | //those cases are easy and have no critera, if we're here we need to check criteria 56 | for (let c of relation.criteria ?? []) { 57 | for (let [prop, val] of Object.entries(c)) { 58 | if (typeof val == "string") { 59 | //@ts-expect-error (doesn't like accessing property with string) 60 | return alert.entities.find(e => e[prop] == val) != null 61 | } else if (val.test) { 62 | //@ts-expect-error (as above + isn't sure what `val` is but we are) 63 | return alert.entities.find(e => (new RegExp(val.test, val.flags)).test(e[prop])) != null 64 | } 65 | } 66 | } 67 | //should only be reached if there are criteria defined but no informed entities 68 | return false 69 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | dotenv.config() 3 | import express from 'express' 4 | import path from 'path' 5 | import syncServices from './util/syncServices' 6 | import { MongoClient } from 'mongodb' 7 | import ActivityPubExpress, { ApexRoutes } from 'activitypub-express' 8 | import parseConfig from './util/parseConfigFile' 9 | import getJobs from './alerts/makeRefreshJobs' 10 | import { ToadScheduler } from 'toad-scheduler' 11 | import http from 'http' 12 | import { Service } from './models/config' 13 | 14 | const app = express() 15 | const port = process.env.PORT ?? 8080 16 | 17 | const routes: ApexRoutes = { 18 | actor: '/u/:actor', 19 | object: '/o/:id', 20 | activity: '/s/:id', 21 | inbox: '/u/:actor/inbox', 22 | outbox: '/u/:actor/outbox', 23 | followers: '/u/:actor/followers', 24 | following: '/u/:actor/following', 25 | liked: '/u/:actor/liked', 26 | collections: '/u/:actor/c/:id', 27 | blocked: '/u/:actor/blocked', 28 | rejections: '/u/:actor/rejections', 29 | rejected: '/u/:actor/rejected', 30 | shares: '/s/:id/shares', 31 | likes: '/s/:id/likes' 32 | } 33 | const apex = ActivityPubExpress({ 34 | name: 'Transit Fedilerts', 35 | version: '0.1.0', 36 | baseUrl: "https://" + process.env.DOMAIN ?? "", 37 | actorParam: 'actor', 38 | objectParam: 'id', 39 | activityParam: 'id', 40 | itemsPerPage: 100, 41 | routes, 42 | //below is from activitypub-express boilerplate, but seems to have opposite intended effect...? 43 | // delivery done in workers only in production 44 | // offlineMode: process.env.NODE_ENV === 'production', 45 | }) 46 | 47 | const client = new MongoClient(process.env.MONGO_URI ?? 'mongodb://localhost:27017') 48 | 49 | const serveActorPage = (req: express.Request, res: express.Response, next: express.NextFunction) => { 50 | if (!req.accepts('html')) { 51 | return next() 52 | } 53 | //verify actor exists first 54 | res.sendFile(path.join(__dirname, '../public/actor.html')) 55 | } 56 | 57 | const serveAcitivityPage = (req: express.Request, res: express.Response, next: express.NextFunction) => { 58 | if (!req.accepts('html')) { 59 | return next() 60 | } 61 | //verify actor exists first 62 | res.sendFile(path.join(__dirname, '../public/activity.html')) 63 | } 64 | 65 | app.use( 66 | express.json({ type: apex.consts.jsonldTypes }), 67 | express.urlencoded({ extended: true }), 68 | apex 69 | ) 70 | // define routes using prepacakged middleware collections 71 | app.route(routes.inbox) 72 | .get(apex.net.inbox.get) 73 | .post(apex.net.inbox.post) 74 | app.route(routes.outbox) 75 | .get(apex.net.outbox.get) 76 | .post(apex.net.outbox.post) 77 | app.get(routes.actor, serveActorPage, apex.net.actor.get) 78 | app.get(routes.followers, apex.net.followers.get) 79 | app.get(routes.following, apex.net.following.get) 80 | app.get(routes.liked, apex.net.liked.get) 81 | app.get(routes.object, apex.net.object.get) 82 | app.get(routes.activity, serveAcitivityPage, apex.net.activityStream.get) 83 | app.get(routes.shares, apex.net.shares.get) 84 | app.get(routes.likes, apex.net.likes.get) 85 | app.get('/.well-known/webfinger', apex.net.webfinger.get) 86 | app.get('/.well-known/nodeinfo', apex.net.nodeInfoLocation.get) 87 | app.get('/nodeinfo/:version', apex.net.nodeInfo.get) 88 | 89 | app.on('apex-outbox', (msg: any) => { 90 | console.log(`[OUTBOX] New ${msg.object.type} from ${msg.actor}`) 91 | }) 92 | app.on('apex-inbox', async (msg: any) => { 93 | if (!msg.actor || !msg.recipient || !msg.activity) { 94 | return 95 | } 96 | const info = `${msg.activity.type} activity${msg.object ? ` on ${msg.activity.type}` : ``}` 97 | console.log(`New ${info} from ${msg.actor.id} to ${msg.recipient.id}`) 98 | const {activity, recipient, actor} = msg 99 | switch (activity.type.toLowerCase()) { 100 | // automatically accept follow requests 101 | // clutters outbox--need a way to adjust post count 102 | case 'follow': { 103 | const accept = await apex.buildActivity('Accept', recipient.id, actor.id, { 104 | object: activity.id 105 | }) 106 | const { postTask: publishUpdatedFollowers } = await apex.acceptFollow(recipient, activity) 107 | await apex.addToOutbox(recipient, accept) 108 | return publishUpdatedFollowers() 109 | } 110 | } 111 | }) 112 | 113 | app.get('/stats', async (req, res, next) => { 114 | try { 115 | const queueSize = await apex.store.db.collection('deliveryQueue') 116 | .countDocuments({ attempt: 0 }) 117 | const uptime = process.uptime() 118 | res.json({ queueSize, uptime }) 119 | } catch (err) { 120 | next(err) 121 | } 122 | }) 123 | 124 | //shamlessly copied from gup.pe implementation: will likely need to adjust with time 125 | app.get('/fedilerts/services', async (req, res) => { 126 | apex.store.db.collection('streams') 127 | .aggregate([ 128 | { $sort: { _id: -1 } }, // start from most recent 129 | { $limit: 10000 }, // don't traverse the entire history 130 | { $match: { type: 'Create' } }, 131 | { $group: { _id: '$actor', postCount: { $sum: 1 } } }, 132 | { $lookup: { from: 'objects', localField: '_id', foreignField: 'id', as: 'actor' } }, 133 | // merge joined actor up 134 | { $replaceRoot: { newRoot: { $mergeObjects: [{ $arrayElemAt: ['$actor', 0] }, '$$ROOT'] } } }, 135 | { $match: { _meta: { $exists: true } } }, 136 | { $project: { _id: 0, _meta: 0, actor: 0 } } 137 | ]) 138 | .toArray() 139 | .then(services => services.map(s => { 140 | return { 141 | id: s.preferredUsername[0], 142 | name: s.name[0], 143 | posts: s.postCount, 144 | note: servicesById[s.preferredUsername[0]]?.summaryNote, 145 | } 146 | })) 147 | .then(s => s.sort((a,b) => a.name.localeCompare(b.name))) 148 | .then(data => res.json({ 149 | domain: process.env.DOMAIN, 150 | services: data, 151 | })) 152 | .catch(err => { 153 | console.log(err.message) 154 | return res.status(500).send() 155 | }) 156 | }) 157 | 158 | app.use('/f', express.static('public/files')) 159 | app.use('/', express.static('public')) 160 | 161 | const scheduler = new ToadScheduler() 162 | 163 | function parseProxyMode(proxyMode: string) { 164 | try { 165 | // boolean or number 166 | return JSON.parse(proxyMode) 167 | } catch (ignore) {} 168 | // string 169 | return proxyMode 170 | } 171 | 172 | let servicesById : {[serviceId: string]: Service} = {} 173 | 174 | client.connect() 175 | .then(() => { 176 | apex.store.db = client.db(process.env.MONGO_DB_NAME ?? 'transitFedilerts') 177 | return apex.store.setup() 178 | }) 179 | .then(() => parseConfig()) 180 | .then((file) => { 181 | file.services.forEach(s => servicesById[s.identifier] = s) 182 | return Promise.all([ 183 | syncServices(file, apex), 184 | getJobs(file.feeds, apex, client), 185 | ]) 186 | }) 187 | .then(([_v, jobs]) => { 188 | if (process.env.NO_FETCH_ALERTS) { 189 | return 190 | } 191 | jobs.forEach(j => scheduler.addIntervalJob(j)) 192 | }) 193 | .then(() => { 194 | if (process.env.PROXY_MODE) { 195 | const mode = parseProxyMode(process.env.PROXY_MODE) 196 | app.set('trust proxy', mode) 197 | } 198 | const server = http.createServer(app) 199 | server.listen(port, () => console.log(`Transit Fedilerts listening on port ${port}`)) 200 | }) 201 | .catch(err => { 202 | console.error(err) 203 | process.exit(1) 204 | }) 205 | -------------------------------------------------------------------------------- /src/models/alert.ts: -------------------------------------------------------------------------------- 1 | export interface TransitAlert { 2 | id: string 3 | date: Date | null 4 | header?: string 5 | body?: string 6 | entities: TransitAlertEntity[] 7 | } 8 | 9 | export interface TransitAlertEntity { 10 | agencyId?: string 11 | routeId?: string 12 | directionId?: number 13 | routeType?: number 14 | stopId?: string 15 | } -------------------------------------------------------------------------------- /src/models/config.ts: -------------------------------------------------------------------------------- 1 | export default interface ConfigFile { 2 | services : Service[] 3 | feeds : Feed[] 4 | } 5 | 6 | export interface Service { 7 | identifier : string 8 | name : string 9 | displayName? : string 10 | iconUrl? : string 11 | summaryNote? : string 12 | } 13 | 14 | export interface Feed { 15 | url : string 16 | headers?: any 17 | relatesTo : FeedRelationToService[] 18 | pollInterval?: number 19 | type?: "gtfsrt" | "cta" 20 | } 21 | 22 | export interface FeedServiceRelationRegexCriteriaValue { 23 | test: string 24 | flags?: string 25 | } 26 | 27 | export type FeedServiceRelationCriteriaValue = string | FeedServiceRelationRegexCriteriaValue 28 | 29 | export interface FeedServiceRelationCriteria { 30 | [entityField: string]: FeedServiceRelationCriteriaValue 31 | } 32 | 33 | export interface FeedRelationToServiceComplex { 34 | identifier: string 35 | criteria?: FeedServiceRelationCriteria[] 36 | } 37 | 38 | export type FeedRelationToService = string | FeedRelationToServiceComplex -------------------------------------------------------------------------------- /src/plugins/cta/index.ts: -------------------------------------------------------------------------------- 1 | import { TransitAlert, TransitAlertEntity } from '../../models/alert' 2 | import { Feed } from '../../models/config' 3 | import { DateTime } from 'luxon' 4 | 5 | async function fetchFeed(feed: Feed) { 6 | const res = await fetch(feed.url, { headers: feed.headers }) 7 | if (!res.ok) { 8 | const error = new Error(`${res.url}: ${res.status} ${res.statusText}`) 9 | throw error 10 | } 11 | return await res.json() 12 | } 13 | 14 | function convertToTransitAlert(alert: any) : TransitAlert { 15 | const impacts : any[] = Array.isArray(alert.ImpactedService.Service) ? alert.ImpactedService.Service : [alert.ImpactedService.Service] 16 | const entities = impacts.map(i => convertImpactedService(i)) 17 | const date = DateTime.fromISO(alert.EventStart, { zone: 'America/Chicago' }) 18 | return { 19 | id: alert.AlertId, 20 | header: alert.Headline, 21 | body: alert.ShortDescription, //FullDescription["#cdata-section"] also an option, but really long and full of tags 22 | date: date.toJSDate(), 23 | entities, 24 | } 25 | } 26 | 27 | function convertImpactedService(i: any) : TransitAlertEntity { 28 | const routeId = ["R","B"].includes(i.ServiceType) ? i.ServiceId : undefined 29 | const stopId = i.ServiceType == "T" ? i.ServiceId : undefined 30 | const routeType = i.ServiceType == "B" ? 3 : i.ServiceType == "R" ? 1 : undefined 31 | return { 32 | routeId, 33 | stopId, 34 | routeType, 35 | } 36 | } 37 | 38 | export default async function getCTAAlerts(feed: Feed) { 39 | return fetchFeed(feed) 40 | .then(f => f.CTAAlerts.Alert) 41 | .then((alerts: any[]) => alerts.map((a: any) => convertToTransitAlert(a))) 42 | } -------------------------------------------------------------------------------- /src/plugins/gtfsrt/index.ts: -------------------------------------------------------------------------------- 1 | import GtfsRealtimeBindings from 'gtfs-realtime-bindings' 2 | import { TransitAlert, TransitAlertEntity } from '../../models/alert' 3 | import {Feed} from "../../models/config" 4 | 5 | async function fetchFeed(feed: Feed) { 6 | const res = await fetch(feed.url, { headers: feed.headers }) 7 | if (!res.ok) { 8 | const error = new Error(`${res.url}: ${res.status} ${res.statusText}`) 9 | throw error 10 | } 11 | const buffer = await res.arrayBuffer() 12 | const feedData = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode( 13 | new Uint8Array(buffer) 14 | ) 15 | return feedData.entity 16 | } 17 | 18 | function convertAlertToTransitAlert(feedEntity: GtfsRealtimeBindings.transit_realtime.IFeedEntity) : TransitAlert { 19 | if (!feedEntity.alert) { 20 | throw new Error("provided an entity without an alert") 21 | } 22 | const start = feedEntity.alert.activePeriod?.[0]?.start 23 | const alertDate = start ? new Date(Number(start) * 1000) : null 24 | const body = feedEntity.alert.descriptionText?.translation?.[0]?.text 25 | const header = feedEntity.alert.headerText?.translation?.[0]?.text 26 | const entities = feedEntity.alert.informedEntity?.map(e => convertAlertEntityToTransitAlertEntity(e)) ?? [] 27 | return { 28 | id: feedEntity.id, 29 | date: alertDate, 30 | entities, 31 | body, header 32 | } 33 | } 34 | 35 | function convertAlertEntityToTransitAlertEntity(entity: GtfsRealtimeBindings.transit_realtime.IEntitySelector) : TransitAlertEntity { 36 | return { 37 | agencyId: entity.agencyId ?? undefined, 38 | routeId: entity.routeId ?? undefined, 39 | directionId: entity.directionId ?? undefined, 40 | routeType: entity.routeType ?? undefined, 41 | stopId: entity.stopId ?? undefined, 42 | } 43 | } 44 | 45 | export default async function getAlertsFromGtfsRt(feed: Feed) { 46 | return fetchFeed(feed) 47 | .then(f => f.filter(f => f.alert != null)) 48 | .then(alerts => alerts.map(a => convertAlertToTransitAlert(a))) 49 | } -------------------------------------------------------------------------------- /src/typings/activitypub-express/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module 'activitypub-express' { 3 | import { Db } from "mongodb" 4 | interface AnyArgs { 5 | [key:string] : any 6 | } 7 | interface ApexConsts { 8 | /** `[ 9 | 'https://www.w3.org/ns/activitystreams', 10 | 'https://w3id.org/security/v1' 11 | ]` */ 12 | ASContext: string[], 13 | /** `'application/x-www-form-urlencoded'` */ 14 | formUrlType: string, 15 | /** `[ 16 | // req.accepts uses accepts which does match profile 17 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 18 | 'application/activity+json', 19 | // req.is uses type-is which cannot handle profile 20 | 'application/ld+json' 21 | ]` */ 22 | jsonldTypes: string[], 23 | // type-is is not able to match this pattern 24 | /** `'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'` */ 25 | jsonldOutgoingType: string, 26 | // since we use json-ld procedding, it will always appear this way regardless of input format 27 | /** 'as:Public' */ 28 | publicAddress: string, 29 | } 30 | interface ApexParams { 31 | name : string 32 | version : string 33 | baseUrl : string 34 | context? : any 35 | store? : any 36 | actorParam : string 37 | activityParam : string 38 | collectionParam? : string 39 | objectParam : string 40 | pageParam?: string 41 | itemsPerPage : number 42 | threadDepth? : number 43 | systemUser? : any 44 | logger? : any 45 | offlineMode? : boolean 46 | requestTimeout? : number 47 | routes: ApexRoutes 48 | endpoints? : AnyArgs 49 | } 50 | interface ApexRoutes extends AnyArgs { 51 | actor: string, 52 | object: string, 53 | activity: string, 54 | inbox: string, 55 | outbox: string, 56 | followers: string, 57 | following: string, 58 | liked: string, 59 | collections: string, 60 | blocked: string, 61 | rejections: string, 62 | rejected: string, 63 | shares: string, 64 | likes: string, 65 | } 66 | 67 | interface ApexActor extends AnyArgs { 68 | 69 | } 70 | type CreateApexActor = (username: string, displayName: string, summary: string, icon: any, type?: string) => Promise 71 | 72 | interface ApexNet extends AnyArgs { 73 | webfinger: { 74 | respondNodeInfo: any, 75 | respondNodeInfoLocation: any, 76 | parseWebfinger: any, 77 | respondWebfinger: any, 78 | get: any[], 79 | } 80 | } 81 | 82 | interface ApexStore extends AnyArgs { 83 | db : Db 84 | updateObject : (obj: any, actorId: string, fullReplace: boolean) => Promise, 85 | saveObject : (obj: any) => Promise, 86 | } 87 | 88 | type ActivityPubExpress = { 89 | (req: Express.Request, res: Express.Response) : any, 90 | consts : ApexConsts, 91 | net : ApexNet, 92 | store : ApexStore, 93 | utils : any, 94 | acceptFollow : (actor: any, activity: any) => Promise, 95 | addToOutbox : (actor: any, activity: any) => Promise, 96 | audienceFromActivity: (activity: any) => string[], 97 | buildActivity : (type: string, actorId: string, to: any, etc: any | undefined) => Promise, 98 | createActor : CreateApexActor, 99 | publishUndoUpdate: (colId: string, actor: ApexActor, audience: string[]) => Promise, 100 | toJSONLD: (obj: any) => any, 101 | } 102 | 103 | 104 | export default function init(params: ApexParams) : ActivityPubExpress 105 | } -------------------------------------------------------------------------------- /src/util/parseConfigFile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import ConfigFile from '../models/config' 3 | 4 | export default async function parseConfigFile() { 5 | return fs.readFile(process.env.SERVICES_JSON ?? 'services.json', 'utf-8') 6 | .then(file => { 7 | return JSON.parse(file) as ConfigFile 8 | }) 9 | } -------------------------------------------------------------------------------- /src/util/syncServices.ts: -------------------------------------------------------------------------------- 1 | import { ActivityPubExpress, ApexActor } from 'activitypub-express' 2 | import ConfigFile from '../models/config' 3 | 4 | export default async function syncServices(file: ConfigFile, apex: ActivityPubExpress) { 5 | checkConfigValid(file) 6 | //return promises for updating/creating services as desired 7 | return Promise.all( 8 | file.services.map(service => { 9 | const icon = service.iconUrl ? {type: "Image", url: service.iconUrl} : null 10 | if (icon?.url.startsWith("/")) { 11 | icon.url = "https://" + process.env.DOMAIN + "/f" + icon.url 12 | } 13 | const summary = "Automated service alerts for " + service.name + (service.summaryNote ? ". " + service.summaryNote : "") 14 | const displayName = service.displayName ?? (service.name + " Alerts") 15 | return apex.createActor(service.identifier, displayName, summary, icon) 16 | }) 17 | ) 18 | .then((actors) => { 19 | return Promise.all(actors.map(async (a) => { 20 | return { 21 | existingObject: await apex.store.getObject(a.id), 22 | actor: a, 23 | } 24 | })) 25 | }) 26 | .then(data => { 27 | return Promise.all(data.map(async (a) => { 28 | if (a.existingObject != null) { 29 | const changes = updatedServiceValues(a.actor, a.existingObject) 30 | if (changes) { 31 | await apex.store.updateObject(changes, a.actor.id, false) 32 | const updated = await apex.store.getObject(a.actor.id, true) 33 | const activity = await apex.buildActivity('Update', a.actor.id, "https://www.w3.org/ns/activitystreams#Public", { 34 | object: a.actor.id, 35 | cc: a.actor.id + "/followers", 36 | }) 37 | return apex.addToOutbox(updated, activity) 38 | } else { 39 | return Promise.resolve() 40 | } 41 | } else { 42 | return apex.store.saveObject(a.actor) 43 | } 44 | })) 45 | }) 46 | } 47 | 48 | function updatedServiceValues(expected: ApexActor, actual: any) { 49 | let val : any = {} 50 | //values are stored as arrays but only contain one element for our purposes 51 | if (expected.name[0] != actual.name[0]) { 52 | val.name = expected.name 53 | } 54 | if (expected.summary[0] != actual.summary[0]) { 55 | val.summary = expected.summary 56 | } 57 | if (expected.icon?.[0]?.url != actual.icon?.[0]?.url) { 58 | if (expected.icon?.[0]?.url) { 59 | //update case 60 | val.icon = [{ 61 | type: "Image", 62 | url: expected.icon[0].url, 63 | }] 64 | } else { 65 | //delete case 66 | val.icon = null 67 | } 68 | } 69 | if (Object.keys(val).length > 0) { 70 | val.id = actual.id 71 | return val 72 | } else { 73 | return null 74 | } 75 | } 76 | 77 | /** 78 | * Throws if the provided config file is invalid, and logs warning to console 79 | * if possible issues are found. 80 | */ 81 | function checkConfigValid(file: ConfigFile) { 82 | const serviceIds = new Set() 83 | const serviceIdsWithFeeds = new Set() 84 | for (let service of file.services) { 85 | if (serviceIds.has(service.identifier)) { 86 | throw new Error("service ID " + service.identifier + " appears more than once") 87 | } 88 | serviceIds.add(service.identifier) 89 | } 90 | for (let feed of file.feeds) { 91 | for (let s of feed.relatesTo) { 92 | const id = typeof s == "string" ? s : s.identifier 93 | if (!serviceIds.has(id)) { 94 | throw new Error("feed URL " + feed.url + " relates to service ID " + id + ", which does not exist") 95 | } 96 | serviceIdsWithFeeds.add(id) 97 | } 98 | if (feed.relatesTo.length == 0) { 99 | console.warn("feed URL " + feed.url + " does not relate to any feeds") 100 | } 101 | } 102 | if (serviceIds.size != serviceIdsWithFeeds.size) { 103 | const servicesWithNoFeeds = Array.from(serviceIds).filter(s => !serviceIdsWithFeeds.has(s)) 104 | console.warn(`service(s) ${servicesWithNoFeeds.join(',')} do(es) not have related feeds`) 105 | } 106 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "outDir": "./dist", 6 | "sourceMap": true, 7 | "strict": true, 8 | "typeRoots": ["node_modules/@types", "src/typings"] 9 | }, 10 | "include": ["src/*"] 11 | } --------------------------------------------------------------------------------