From 1d5a3c1e51f62132247f2f931f2986c9641ac949 Mon Sep 17 00:00:00 2001 From: Matthias Kesler Date: Mon, 20 Jan 2025 14:40:08 +0100 Subject: [PATCH] WIP: Add websocket connection for streaming toggl updates --- package.json | 3 +- src/config.ts | 26 ++++++++ src/main.ts | 45 +++++++++++++- src/toggl-stream.ts | 143 ++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 16 +++++ 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 src/toggl-stream.ts diff --git a/package.json b/package.json index aa04639..38ebe7b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ }, "dependencies": { "@companion-module/base": "~1.13.4", - "toggl-track": "https://github.com/krombel/toggl-track#v0.8.0-2" + "toggl-track": "https://github.com/krombel/toggl-track#v0.8.0-2", + "ws": "^8.18.0" }, "devDependencies": { "@companion-module/tools": "~2.4.2", diff --git a/src/config.ts b/src/config.ts index 4b7ef84..ab562e1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,8 @@ export interface ModuleConfig { alwaysStart: boolean startTimerPoller: boolean timerPollerInterval: number + startTogglWebsocket: boolean + wsToken?: string } export function GetConfigFields(): SomeCompanionConfigField[] { @@ -49,6 +51,30 @@ export function GetConfigFields(): SomeCompanionConfigField[] { default: 60, min: 30, max: 3600, + isVisible: (opt) => { + return opt.startTimerPoller === true + }, + }, + { + type: 'checkbox', + id: 'startTogglWebsocket', + label: 'EXPERIMENTAL: Use background websocket connection for updates', + width: 12, + default: false, + isVisible: (opt) => { + return !opt.startTimerPoller + }, + }, + { + type: 'textinput', + id: 'wsToken', + label: 'Personal API Token from your Toggl user profile (required)', + width: 12, + required: true, + default: '', + isVisible: (opt) => { + return !opt.startTimerPoller && opt.startTogglWebsocket === true + }, }, ] } diff --git a/src/main.ts b/src/main.ts index 0071527..47347d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ // toggltrack module // Peter Daniel, Matthias Kesler -import { InstanceBase, runEntrypoint, InstanceStatus, SomeCompanionConfigField } from '@companion-module/base' +import { InstanceBase, runEntrypoint, InstanceStatus, SomeCompanionConfigField, LogLevel } from '@companion-module/base' import { GetConfigFields, type ModuleConfig } from './config.js' import { UpdatePresets } from './presets.js' import { UpdateVariableDefinitions } from './variables.js' @@ -11,11 +11,13 @@ import { UpdateFeedbacks } from './feedbacks.js' import { Toggl, ITimeEntry, IWorkspaceProject, IClient, isRatelimitError } from 'toggl-track' import { togglGetWorkspaces } from './toggl-extend.js' import { timecodeSince } from './utils.js' +import { TogglStream } from './toggl-stream.js' export class TogglTrack extends InstanceBase { config!: ModuleConfig // Setup in init() toggl?: Toggl + stream?: TogglStream workspaceId?: number // current active workspace id workspaceName: string = '' // name of workspace @@ -61,6 +63,10 @@ export class TogglTrack extends InstanceBase { if (this.config.startTimerPoller) { this.startTimeEntryPoller() } + + if (this.config.startTogglWebsocket) { + await this.startToggleStream() + } } // When module gets deleted @@ -116,6 +122,14 @@ export class TogglTrack extends InstanceBase { this.stopTimeEntryPoller() } } + if (this.config.startTogglWebsocket) { + await this.startToggleStream() + } else { + if (this.stream) { + await this.stream.destroy() + delete this.stream + } + } this.updateActions() //this.updateVariables() @@ -193,6 +207,35 @@ export class TogglTrack extends InstanceBase { clearInterval(this.intervalId) } + private async startToggleStream(): Promise { + if (this.stream) { + await this.stream.destroy() + delete this.stream + } + this.stream = new TogglStream({ + apiToken: this.config.wsToken!, + reconnect: true, + log: (level: LogLevel, message: string) => { + this.log(level, 'ws: ' + message) + }, + onTimeEntryCreated: (e: ITimeEntry) => { + if (!e.stop) { + this.setCurrentlyRunningTimeEntry(e) + } + }, + onTimeEntryUpdate: (e: ITimeEntry) => { + if (e.id == this.currentEntry?.id && e.stop) { + // currently running timer got a stop entry + this.setCurrentlyRunningTimeEntry(undefined) + } else { + // store update on this time entry (e.g. rename) + this.setCurrentlyRunningTimeEntry(e) + } + }, + }) + this.stream.start() + } + /** * Set variables to this time entry * @param entry running entry or undefined diff --git a/src/toggl-stream.ts b/src/toggl-stream.ts new file mode 100644 index 0000000..d6193e2 --- /dev/null +++ b/src/toggl-stream.ts @@ -0,0 +1,143 @@ +import { LogLevel } from '@companion-module/base' +import { ITimeEntry } from 'toggl-track' +import WebSocket from 'ws' + +interface IToggleStreamConfig { + apiToken: string + reconnect?: boolean + onMessage?: (body: string) => void + log: (level: LogLevel, message: string) => void + onTimeEntryCreated: (entry: ITimeEntry) => void + onTimeEntryUpdate: (entry: ITimeEntry) => void + onTimeEntryDelete?: (entry: ITimeEntry) => void +} + +const url = 'wss://doh.krombel.de/toggl-websocket' + +export class TogglStream { + ws?: WebSocket + apiToken: string + reconnect: boolean + ping_timer?: NodeJS.Timeout + reconnect_timer?: NodeJS.Timeout + + log: (level: LogLevel, message: string) => void + onTimeEntryCreated: (entry: ITimeEntry) => void + onTimeEntryUpdate: (entry: ITimeEntry) => void + onTimeEntryDelete?: (entry: ITimeEntry) => void + + constructor({ apiToken, reconnect, log, onTimeEntryCreated, onTimeEntryUpdate }: IToggleStreamConfig) { + this.apiToken = apiToken + this.reconnect = Boolean(reconnect) + this.log = log + this.onTimeEntryCreated = onTimeEntryCreated + this.onTimeEntryUpdate = onTimeEntryUpdate + } + + async destroy(): Promise { + clearInterval(this.ping_timer) + this.ping_timer = undefined + if (this.reconnect_timer) { + clearTimeout(this.reconnect_timer) + this.reconnect_timer = undefined + } + if (this.ws) { + this.ws.close(1000) + delete this.ws + } + } + + start(): void { + this.log('debug', 'startToggleStream') + if (this.ping_timer) { + clearInterval(this.ping_timer) + this.ping_timer = undefined + } + if (this.reconnect_timer) { + clearTimeout(this.reconnect_timer) + this.reconnect_timer = undefined + } + if (this.ws) { + this.ws.close(1000) + delete this.ws + } + + this.ws = new WebSocket(url) + this.ws.addEventListener('open', () => { + this.log('debug', 'websocket connection opened') + + this.ws?.send( + JSON.stringify({ + type: 'authenticate', + api_token: this.apiToken, + }), + ) + }) + this.ws.on('close', (code) => { + this.log('debug', `websocket connection closed with code ${code}`) + }) + this.ws.on('error', (err) => { + this.log('debug', `websocket connection errored with code ${err.message}`) + this.maybeReconnect() + }) + this.ws.on('message', this.onMessageReceived.bind(this)) + this.ping_timer = setInterval(() => { + this.ws?.ping() + }, 25000) // send ping every 25 seconds + } + + private maybeReconnect(): void { + if (this.reconnect) { + clearTimeout(this.reconnect_timer) + this.reconnect_timer = setTimeout(() => { + this.start() + }, 5000) + } + } + + private onMessageReceived(data: WebSocket.RawData, isBinary: boolean): void { + this.log('debug', 'Got ' + (isBinary ? 'binary' : 'string') + ' message: ' + JSON.stringify(data)) + + const message = (data as Buffer).toString() + let msgValue // IToggleStreamPing | string + try { + msgValue = JSON.parse(message) + } catch (e) { + this.log('warn', 'Failed parsing message' + JSON.stringify(e)) + msgValue = data + } + this.log('debug', 'parsed message: ' + JSON.stringify(msgValue)) + if ('type' in msgValue && msgValue.type == 'ping') { + this.ws?.send( + JSON.stringify({ + type: 'pong', + }), + ) + return + } + + if ('action' in msgValue) { + switch (msgValue.action) { + case 'update': // stop: + // {"action":"update","data":{"id":3766092771,"wid":3516429,"duration":4115,"duration_diff":4115,"pid":193750968,"project_name":"Notizen","project_color":"#c7af14","project_active":true,"project_billable":false,"tid":null,"tag_ids":null,"tags":[],"description":"StreamDeck","billable":false,"duronly":true,"start":"2025-01-17T14:14:31Z","stop":"2025-01-17T15:23:06Z","shared_with":null,"at":"2025-01-17T15:23:06.279165Z"},"model":"time_entry"} + if ('model' in msgValue && msgValue.model == 'time_entry') { + this.onTimeEntryUpdate(msgValue.data as ITimeEntry) + return + } + break + case 'insert': // start + // {"action":"insert","data":{"id":3766232279,"wid":3516429,"duration":-1,"pid":193750968,"project_name":"Notizen","project_color":"#c7af14","project_active":true,"project_billable":false,"tid":null,"tag_ids":null,"tags":[],"description":"StreamDeck","billable":false,"duronly":true,"start":"2025-01-17T15:29:32Z","stop":null,"shared_with":null,"at":"2025-01-17T15:29:32.533896Z"},"model":"time_entry"} + if ('model' in msgValue && msgValue.model == 'time_entry') { + this.onTimeEntryCreated(msgValue.data as ITimeEntry) + return + } + break + case 'delete': + if ('model' in msgValue && msgValue.model == 'time_entry') { + this.onTimeEntryDelete!(msgValue.data as ITimeEntry) + return + } + } + } + } +} diff --git a/yarn.lock b/yarn.lock index edd447e..5abd64a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2788,6 +2788,7 @@ __metadata: toggl-track: "https://github.com/krombel/toggl-track#v0.8.0-2" typescript: "npm:~5.9.3" typescript-eslint: "npm:^8.46.2" + ws: "npm:^8.18.0" languageName: unknown linkType: soft @@ -3060,6 +3061,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.18.3 + resolution: "ws@npm:8.18.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 + languageName: node + linkType: hard + "yallist@npm:^5.0.0": version: 5.0.0 resolution: "yallist@npm:5.0.0"