all repos — kokyo @ 1513f141d0c31ef944771e03ce8cc54e7b19bd56

Chatbot and CLI tool for Swiss public transports

Setup basic CLI
Tim Izzo tim@octree.ch
Thu, 08 Aug 2024 17:37:58 +0200
commit

1513f141d0c31ef944771e03ce8cc54e7b19bd56

A .env.example

@@ -0,0 +1,1 @@

+API_TOKEN=
A .gitignore

@@ -0,0 +1,2 @@

+.env* +!.env.example
A api/api.ts

@@ -0,0 +1,75 @@

+import { parse } from "@libs/xml"; + +const TOKEN = Deno.env.get("API_TOKEN"); +const RequestorRef = "Caroster.io"; + +const apiFetch = async ( + query: string +): Promise<{ response: Response; result: any }> => { + const headers = new Headers(); + headers.append("Authorization", `Bearer ${TOKEN}`); + headers.append("Content-Type", "application/xml"); + const response = await fetch("https://api.opentransportdata.swiss/ojp20", { + headers, + method: "POST", + body: query, + }); + const xml = await response.text(); + const result = parse(xml); + return { response, result }; +}; + +// Doc: https://opentransportdata.swiss/fr/cookbook/stopeventservice/ +export const getStopEventService = ( + stopPlaceRef: string, + depArrTime: Date = new Date() +) => + apiFetch(`<?xml version="1.0" encoding="UTF-8"?> +<OJP xmlns="http://www.vdv.de/ojp" xmlns:siri="http://www.siri.org.uk/siri" version="2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.vdv.de/ojp ../../../../OJP4/OJP.xsd"> + <OJPRequest> + <siri:ServiceRequest> + <siri:RequestTimestamp>${new Date().toISOString()}</siri:RequestTimestamp> + <siri:RequestorRef>${RequestorRef}</siri:RequestorRef> + <OJPStopEventRequest> + <siri:RequestTimestamp>${new Date().toISOString()}</siri:RequestTimestamp> + <siri:MessageIdentifier>1220</siri:MessageIdentifier> + <Location> + <PlaceRef> + <siri:StopPointRef>${stopPlaceRef}</siri:StopPointRef> + </PlaceRef> + <DepArrTime>${depArrTime.toISOString()}</DepArrTime> + </Location> + <Params> + <NumberOfResults>5</NumberOfResults> + <StopEventType>departure</StopEventType> + <IncludePreviousCalls>false</IncludePreviousCalls> + <IncludeOnwardCalls>false</IncludeOnwardCalls> + <UseRealtimeData>full</UseRealtimeData> + <IncludeStopHierarchy>all</IncludeStopHierarchy> + </Params> + </OJPStopEventRequest> + </siri:ServiceRequest> + </OJPRequest> +</OJP>`); + +// Doc: https://opentransportdata.swiss/fr/cookbook/OJPLocationInformationRequest/ +export const getLocationInformationRequest = (textInput: string) => + apiFetch(`<OJP xmlns="http://www.vdv.de/ojp" xmlns:siri="http://www.siri.org.uk/siri" version="2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.vdv.de/ojp ../../../../Downloads/OJP-changes_for_v1.1%20(1)/OJP-changes_for_v1.1/OJP.xsd"> + <OJPRequest> + <siri:ServiceRequest> + <siri:RequestTimestamp>${new Date().toISOString()}</siri:RequestTimestamp> + <siri:RequestorRef>${RequestorRef}</siri:RequestorRef> + <OJPLocationInformationRequest> + <siri:RequestTimestamp>${new Date().toISOString()}</siri:RequestTimestamp> + <siri:MessageIdentifier>1220</siri:MessageIdentifier> + <InitialInput> + <Name>${textInput}</Name> + </InitialInput> + <Restrictions> + <Type>stop</Type> + <NumberOfResults>5</NumberOfResults> + </Restrictions> + </OJPLocationInformationRequest> + </siri:ServiceRequest> + </OJPRequest> +</OJP>`);
A api/locationInformationRequest.ts

@@ -0,0 +1,18 @@

+import { get } from "../lib/func.ts"; +import { getLocationInformationRequest } from "./api.ts"; + +export const findStopByName = async (textInput: string): Promise<Place[]> => { + const { result } = await getLocationInformationRequest(textInput); + const placeResult = get<object[]>( + result, + "OJP.OJPResponse.siri:ServiceDelivery.OJPLocationInformationDelivery.PlaceResult" + ); + return placeResult.map(item => ({ + name: get(item, "Place.StopPlace.StopPlaceName.Text.#text"), + stopRef: get(item, "Place.StopPlace.StopPlaceRef"), + geoPosition: { + latitude: get(item, "Place.GeoPosition.siri:Latitude"), + longitude: get(item, "Place.GeoPosition.siri:Longitude"), + }, + })); +};
A api/stopEvent.ts

@@ -0,0 +1,52 @@

+import { difference } from "@std/datetime/difference"; +import { format } from "@std/datetime/format"; +import { getStopEventService } from "./api.ts"; +import { get } from "../lib/func.ts"; + +export const getNextDepartures = async ( + stopRef: string, + depArrTime: Date = new Date() +): Promise<StopEvent[]> => { + const { result } = await getStopEventService(stopRef, depArrTime); + const stopEventResult = + result.OJP.OJPResponse["siri:ServiceDelivery"]["OJPStopEventDelivery"][ + "StopEventResult" + ]; + + return stopEventResult + .map((stopEvent: any) => { + const rawDatetime = + get( + stopEvent, + "StopEvent.ThisCall.CallAtStop.ServiceDeparture.EstimatedTime" + ) || + get( + stopEvent, + "StopEvent.ThisCall.CallAtStop.ServiceDeparture.TimetabledTime" + ); + let datetime = null; + let departureIn = null; + if (rawDatetime) { + datetime = new Date(rawDatetime); + departureIn = difference(datetime, new Date(), { units: ["minutes"] }); + } + return { + from: get(stopEvent, "StopEvent.Service.OriginText.Text.#text"), + to: get(stopEvent, "StopEvent.Service.DestinationText.Text.#text"), + departure: datetime && format(datetime, "HH:mm"), + departureIn: departureIn?.minutes, + stopName: get( + stopEvent, + "StopEvent.ThisCall.CallAtStop.StopPointName.Text.#text" + ), + quay: get( + stopEvent, + "StopEvent.ThisCall.CallAtStop.PlannedQuay.Text.#text" + ), + } as StopEvent; + }) + .sort( + (eventA: StopEvent, eventB: StopEvent) => + eventA.departureIn - eventB.departureIn + ); +};
A cli.ts

@@ -0,0 +1,40 @@

+import { Command } from "@cliffy/command"; +import { Input, Select } from "@cliffy/prompt"; +import { colors } from "@cliffy/ansi/colors"; +import { Table } from "@cliffy/table"; +import { findStopByName } from "./api/locationInformationRequest.ts"; +import { getNextDepartures } from "./api/stopEvent.ts"; + +await new Command() + .name("tccli") + .option("-s, --stop <name>", "Nom de l'arrêt") + .action(async options => { + let stopInput = options.stop; + if (!stopInput) + stopInput = await Input.prompt({ + message: "Nom de l'arrêt", + }); + + const stopLists = await findStopByName(stopInput); + const stopRef = await Select.prompt({ + message: "Sélectionner le stop", + options: stopLists.map(item => ({ + name: item.name, + value: item.stopRef, + })), + }); + const nextDepartures = await getNextDepartures(stopRef); + + const tableRows = nextDepartures.map(item => [ + item.departure, + `${item.departureIn} min`, + item.to, + ]); + const table: Table = new Table() + .header(["Départ", "Dans", "Direction"].map(colors.bold.blue)) + .body(tableRows) + .columns([{ minWidth: 10 }, { minWidth: 10 }]); + console.log(""); + table.render(); + }) + .parse();
A demo.tape

@@ -0,0 +1,81 @@

+# VHS documentation +# +# Output: +# Output <path>.gif Create a GIF output at the given <path> +# Output <path>.mp4 Create an MP4 output at the given <path> +# Output <path>.webm Create a WebM output at the given <path> +# +# Require: +# Require <string> Ensure a program is on the $PATH to proceed +# +# Settings: +# Set FontSize <number> Set the font size of the terminal +# Set FontFamily <string> Set the font family of the terminal +# Set Height <number> Set the height of the terminal +# Set Width <number> Set the width of the terminal +# Set LetterSpacing <float> Set the font letter spacing (tracking) +# Set LineHeight <float> Set the font line height +# Set LoopOffset <float>% Set the starting frame offset for the GIF loop +# Set Theme <json|string> Set the theme of the terminal +# Set Padding <number> Set the padding of the terminal +# Set Framerate <number> Set the framerate of the recording +# Set PlaybackSpeed <float> Set the playback speed of the recording +# Set MarginFill <file|#000000> Set the file or color the margin will be filled with. +# Set Margin <number> Set the size of the margin. Has no effect if MarginFill isn't set. +# Set BorderRadius <number> Set terminal border radius, in pixels. +# Set WindowBar <string> Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) +# Set WindowBarSize <number> Set window bar size, in pixels. Default is 40. +# Set TypingSpeed <time> Set the typing speed of the terminal. Default is 50ms. +# +# Sleep: +# Sleep <time> Sleep for a set amount of <time> in seconds +# +# Type: +# Type[@<time>] "<characters>" Type <characters> into the terminal with a +# <time> delay between each character +# +# Keys: +# Escape[@<time>] [number] Press the Escape key +# Backspace[@<time>] [number] Press the Backspace key +# Delete[@<time>] [number] Press the Delete key +# Insert[@<time>] [number] Press the Insert key +# Down[@<time>] [number] Press the Down key +# Enter[@<time>] [number] Press the Enter key +# Space[@<time>] [number] Press the Space key +# Tab[@<time>] [number] Press the Tab key +# Left[@<time>] [number] Press the Left Arrow key +# Right[@<time>] [number] Press the Right Arrow key +# Up[@<time>] [number] Press the Up Arrow key +# Down[@<time>] [number] Press the Down Arrow key +# PageUp[@<time>] [number] Press the Page Up key +# PageDown[@<time>] [number] Press the Page Down key +# Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C) +# +# Display: +# Hide Hide the subsequent commands from the output +# Show Show the subsequent commands in the output + +Output demo.gif + +Require echo +Require deno + +Set Shell "bash" +Set FontSize 32 +Set Width 1200 +Set Height 600 + +Sleep 1s +Type "deno task cli" Sleep 500ms Enter +Sleep 1s +Type "bellins" Sleep 500ms Enter +Sleep 2s +Down +Sleep 500ms +Down +Sleep 500ms +Down +Sleep 500ms +Enter + +Sleep 7s
A deno.json

@@ -0,0 +1,13 @@

+{ + "tasks": { + "cli": "deno run -A --env cli.ts" + }, + "imports": { + "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.5", + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.5", + "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.5", + "@cliffy/ansi": "jsr:@cliffy/ansi@^1.0.0-rc.5", + "@libs/xml": "jsr:@libs/xml@^5.4.13", + "@std/datetime": "jsr:@std/datetime@^0.224.5" + } +}
A deno.lock

@@ -0,0 +1,130 @@

+{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@cliffy/ansi@1.0.0-rc.5": "jsr:@cliffy/ansi@1.0.0-rc.5", + "jsr:@cliffy/ansi@^1.0.0-rc.5": "jsr:@cliffy/ansi@1.0.0-rc.5", + "jsr:@cliffy/command@^1.0.0-rc.5": "jsr:@cliffy/command@1.0.0-rc.5", + "jsr:@cliffy/flags@1.0.0-rc.5": "jsr:@cliffy/flags@1.0.0-rc.5", + "jsr:@cliffy/internal@1.0.0-rc.5": "jsr:@cliffy/internal@1.0.0-rc.5", + "jsr:@cliffy/keycode@1.0.0-rc.5": "jsr:@cliffy/keycode@1.0.0-rc.5", + "jsr:@cliffy/prompt@^1.0.0-rc.5": "jsr:@cliffy/prompt@1.0.0-rc.5", + "jsr:@cliffy/table@1.0.0-rc.5": "jsr:@cliffy/table@1.0.0-rc.5", + "jsr:@cliffy/table@^1.0.0-rc.5": "jsr:@cliffy/table@1.0.0-rc.5", + "jsr:@libs/typing@2": "jsr:@libs/typing@2.8.1", + "jsr:@libs/xml@^5.4.13": "jsr:@libs/xml@5.4.13", + "jsr:@std/assert@1.0.0-rc.2": "jsr:@std/assert@1.0.0-rc.2", + "jsr:@std/cli@1.0.0-rc.2": "jsr:@std/cli@1.0.0-rc.2", + "jsr:@std/datetime@^0.224.5": "jsr:@std/datetime@0.224.5", + "jsr:@std/encoding@1.0.0-rc.2": "jsr:@std/encoding@1.0.0-rc.2", + "jsr:@std/fmt@~0.225.4": "jsr:@std/fmt@0.225.6", + "jsr:@std/io@~0.224.2": "jsr:@std/io@0.224.4", + "jsr:@std/path@1.0.0-rc.2": "jsr:@std/path@1.0.0-rc.2", + "jsr:@std/text@1.0.0-rc.1": "jsr:@std/text@1.0.0-rc.1", + "npm:@types/node": "npm:@types/node@18.16.19" + }, + "jsr": { + "@cliffy/ansi@1.0.0-rc.5": { + "integrity": "85a4dba4da5d8278dcdfeea98672cd15706c244833f82edc60c61f410d9fc1a9", + "dependencies": [ + "jsr:@cliffy/internal@1.0.0-rc.5", + "jsr:@std/encoding@1.0.0-rc.2", + "jsr:@std/fmt@~0.225.4", + "jsr:@std/io@~0.224.2" + ] + }, + "@cliffy/command@1.0.0-rc.5": { + "integrity": "55e00a1d0ae38152fb275a89494a81ffb9b144eb9060107c0be5af46e1ba736c", + "dependencies": [ + "jsr:@cliffy/flags@1.0.0-rc.5", + "jsr:@cliffy/internal@1.0.0-rc.5", + "jsr:@cliffy/table@1.0.0-rc.5", + "jsr:@std/fmt@~0.225.4", + "jsr:@std/text@1.0.0-rc.1" + ] + }, + "@cliffy/flags@1.0.0-rc.5": { + "integrity": "bd33b7b399e0af353f5516d87a2d552d46ee7e7f4a6f0c0bc65fcce750710217", + "dependencies": [ + "jsr:@std/text@1.0.0-rc.1" + ] + }, + "@cliffy/internal@1.0.0-rc.5": { + "integrity": "1e8dca4fcfba1815bf1a899bb880e09f8b45284c352465ef8fb015887c1fc126" + }, + "@cliffy/keycode@1.0.0-rc.5": { + "integrity": "2bcb3cb13873f0b758664394e003fc0cfa751af37a076ca9ec6e574df77aa3a8" + }, + "@cliffy/prompt@1.0.0-rc.5": { + "integrity": "3573a4c5c460fc84dcc554e548acfc2616157b60a61a9781833967c5a76da9f0", + "dependencies": [ + "jsr:@cliffy/ansi@1.0.0-rc.5", + "jsr:@cliffy/internal@1.0.0-rc.5", + "jsr:@cliffy/keycode@1.0.0-rc.5", + "jsr:@std/assert@1.0.0-rc.2", + "jsr:@std/fmt@~0.225.4", + "jsr:@std/io@~0.224.2", + "jsr:@std/path@1.0.0-rc.2", + "jsr:@std/text@1.0.0-rc.1" + ] + }, + "@cliffy/table@1.0.0-rc.5": { + "integrity": "2b3e1b4764bbb56b0c39aeba95bc0bb551d9bd4475fbb6d1ce368c08b7ef9eb3", + "dependencies": [ + "jsr:@std/cli@1.0.0-rc.2", + "jsr:@std/fmt@~0.225.4" + ] + }, + "@libs/typing@2.8.1": { + "integrity": "08437a01ec51f74a20a5ab5d683475025f93a2ad641d2394a97e87f7b5194d78" + }, + "@libs/xml@5.4.13": { + "integrity": "995320d1ce4a29ced82233e5e46d47a880e338197bbd257a686bf9afcc3ac0e4", + "dependencies": [ + "jsr:@libs/typing@2" + ] + }, + "@std/assert@1.0.0-rc.2": { + "integrity": "0484eab1d76b55fca1c3beaff485a274e67dd3b9f065edcbe70030dfc0b964d3" + }, + "@std/cli@1.0.0-rc.2": { + "integrity": "97dfae82b9f0e189768ebfa7a5da53375955b94bad0a1804f8e3b73563b03787" + }, + "@std/datetime@0.224.5": { + "integrity": "363fca6e2e46c1e85139c10ba77745d25c0936abd112b8bfdc9b8fc3615added" + }, + "@std/encoding@1.0.0-rc.2": { + "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" + }, + "@std/fmt@0.225.6": { + "integrity": "aba6aea27f66813cecfd9484e074a9e9845782ab0685c030e453a8a70b37afc8" + }, + "@std/io@0.224.4": { + "integrity": "bce1151765e4e70e376039fd72c71672b4d4aae363878a5ee3e58361b81197ec" + }, + "@std/path@1.0.0-rc.2": { + "integrity": "39f20d37a44d1867abac8d91c169359ea6e942237a45a99ee1e091b32b921c7d" + }, + "@std/text@1.0.0-rc.1": { + "integrity": "34c722203e87ee12792c8d4a0cd2ee0e001341cbce75b860fc21be19d62232b0" + } + }, + "npm": { + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + } + } + }, + "remote": {}, + "workspace": { + "dependencies": [ + "jsr:@cliffy/ansi@^1.0.0-rc.5", + "jsr:@cliffy/command@^1.0.0-rc.5", + "jsr:@cliffy/prompt@^1.0.0-rc.5", + "jsr:@cliffy/table@^1.0.0-rc.5", + "jsr:@libs/xml@^5.4.13", + "jsr:@std/datetime@^0.224.5" + ] + } +}
A lib/func.ts

@@ -0,0 +1,27 @@

+type Path = string | Array<string | number>; + +export const get = <T = string>( + object: Record<string, any>, + path: Path, + defaultValue?: T +): T => { + if (!object || typeof object !== "object") return defaultValue as T; + + const pathArray = Array.isArray(path) + ? path + : path + .split(".") + .map(key => + key.includes("[") ? key.replace(/\[(\d+)\]/g, ".$1") : key + ) + .join(".") + .split("."); + + let result: any = object; + for (let key of pathArray) { + result = result?.[key]; + if (result === undefined) return defaultValue as T; + } + + return result; +};
A result.json

@@ -0,0 +1,60 @@

+{ + headers: Headers { + authorization: "Bearer eyJvcmciOiI2NDA2NTFhNTIyZmEwNTAwMDEyOWJiZTEiLCJpZCI6IjlhOTM4NDZkNTIxZjRhNmI4ZjNhZDgwNDFkODVhMzM5IiwiaCI6Im11cm11cjEyOCJ9", + "content-type": "application/xml" + }, + method: "POST", + body: '<OJP xmlns="http://www.vdv.de/ojp" xmlns:siri="http://www.siri.org.uk/siri" version="2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.vdv.de/ojp ../../../../Downloads/OJP-changes_for_v1.1%20(1)/OJP-changes_for_v1.1/OJP.xsd">\n' + + " <OJPRequest>\n" + + " <siri:ServiceRequest>\n" + + " <siri:RequestTimestamp>2024-08-08T14:33:29.455Z</siri:RequestTimestamp>\n" + + " <siri:RequestorRef>Caroster.io</siri:RequestorRef>\n" + + " <OJPLocationInformationRequest>\n" + + " <siri:RequestTimestamp>2024-08-08T14:33:29.455Z</siri:RequestTimestamp>\n" + + " <siri:MessageIdentifier>1220</siri:MessageIdentifier>\n" + + " <InitialInput>\n" + + " <Name>Verbant</Name>\n" + + " </InitialInput>\n" + + " <Restrictions>\n" + + " <Type>stop</Type>\n" + + " <NumberOfResults>10</NumberOfResults>\n" + + " </Restrictions>\n" + + " </OJPLocationInformationRequest>\n" + + " </siri:ServiceRequest>\n" + + " </OJPRequest>\n" + + "</OJP>" +} +[ + { + "name": "Plan-les-Ouates, Verbant", + "stopRef": "8595038", + "geoPosition": { + "latitude": "46.15728", + "longitude": "6.12567" + } + }, + { + "name": "Porto Ronco, Crodolo Verbano", + "stopRef": "8505451", + "geoPosition": { + "latitude": "46.1331", + "longitude": "8.71899" + } + }, + { + "name": "Minusio, Via Verbano", + "stopRef": "8580987", + "geoPosition": { + "latitude": "46.1749", + "longitude": "8.81667" + } + }, + { + "name": "Bern, Kaufmännischer Verband", + "stopRef": "8590009", + "geoPosition": { + "latitude": "46.94569", + "longitude": "7.42747" + } + } +]
A types.d.ts

@@ -0,0 +1,17 @@

+interface StopEvent { + from: string; + to: string; + departure: string; + departureIn?: number; + stopName: string; + quay: string; +} + +interface Place { + name: string; + stopRef: string; + geoPosition: { + latitude: number; + longitude: number; + }; +}