diff --git a/package.json b/package.json index 90f2bb2..89c76db 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "license": "MIT", "dependencies": { "@companion-module/base": "~1.11.3", - "toggl-track": "^0.8.0" + "toggl-track": "^0.8.0", + "ws": "^8.18.2" }, "devDependencies": { "@companion-module/tools": "~2.3.0", diff --git a/src/config.ts b/src/config.ts index 4b7ef84..101f676 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,8 @@ export interface ModuleConfig { alwaysStart: boolean startTimerPoller: boolean timerPollerInterval: number + startWebSocket: boolean + websocketSecret: string } export function GetConfigFields(): SomeCompanionConfigField[] { @@ -50,5 +52,19 @@ export function GetConfigFields(): SomeCompanionConfigField[] { min: 30, max: 3600, }, + { + type: 'checkbox', + id: 'startWebSocket', + label: 'start Websocket (krombel testing)', + width: 12, + default: false, + }, + { + type: 'textinput', + id: 'websocketSecret', + label: 'secret to authenticate against Websocket server (krombel testing)', + width: 12, + default: 'secret', + }, ] } diff --git a/src/main.ts b/src/main.ts index efc00cc..b16004c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,9 @@ import UpdateVariableDefinitions from './variables.js' import UpgradeScripts from './upgrades.js' import { UpdateFeedbacks } from './feedbacks.js' import { Toggl, ITimeEntry, IWorkspaceProject, IClient } from 'toggl-track' -import { togglGetWorkspaces } from './toggl-extend.js' +import { isITimeEntryWebhookPayload, togglGetWorkspaces } from './toggl-extend.js' import { timecodeSince } from './utils.js' +import WebSocket from 'ws' export class TogglTrack extends InstanceBase { config!: ModuleConfig // Setup in init() @@ -25,6 +26,9 @@ export class TogglTrack extends InstanceBase { intervalId?: NodeJS.Timeout currentTimerUpdaterIntervalId?: NodeJS.Timeout + wsReconnectTimer?: NodeJS.Timeout + + ws?: WebSocket constructor(internal: unknown) { super(internal) @@ -40,6 +44,10 @@ export class TogglTrack extends InstanceBase { this.stopTimeEntryPoller() } + if (this.config.startWebSocket) { + this.stopWebSocket() + } + clearInterval(this.currentTimerUpdaterIntervalId) } @@ -66,6 +74,9 @@ export class TogglTrack extends InstanceBase { if (this.config.startTimerPoller) { this.startTimeEntryPoller() } + if (this.config.startWebSocket) { + this.startWebSocket() + } } async configUpdated(config: ModuleConfig): Promise { @@ -74,6 +85,7 @@ export class TogglTrack extends InstanceBase { const apiTokenChanged: boolean = this.config.apiToken != config.apiToken const workSpaceDefaultChanged: boolean = this.config.workspaceName != config.workspaceName const timeEntryPollerChanged: boolean = this.config.startTimerPoller != config.startTimerPoller + const webSocketChanged: boolean = this.config.startWebSocket != config.startWebSocket this.config = config @@ -94,6 +106,13 @@ export class TogglTrack extends InstanceBase { this.stopTimeEntryPoller() } } + if (webSocketChanged) { + if (this.config.startWebSocket) { + this.startWebSocket() + } else { + this.stopWebSocket() + } + } this.updateActions() //this.updateVariables() @@ -153,6 +172,79 @@ export class TogglTrack extends InstanceBase { clearInterval(this.intervalId) } + private startWebSocket(): void { + this.log('info', 'start websocket') + + this.updateStatus(InstanceStatus.Connecting) + if (this.ws) { + this.ws.close(1000) + delete this.ws + } + + this.ws = new WebSocket('wss://doh.krombel.de/toggl-websocket') + this.ws.addEventListener('open', () => { + this.updateStatus(InstanceStatus.Ok) + this.ws?.send(this.config.websocketSecret) + }) + this.ws.addEventListener('close', (code) => { + this.log('debug', `Connection closed with code ${code.code}`) + this.updateStatus(InstanceStatus.Disconnected, `WS-Connection closed with code ${code.code}`) + this.maybeReconnectWebSocket() + }) + this.ws.addEventListener('message', this.websocketMessageHandler.bind(this)) + this.ws.addEventListener('error', (data) => { + this.log('error', `WebSocket error: ${data.error}`) + }) + } + + private stopWebSocket(): void { + this.log('info', 'stop websocket') + + if (this.wsReconnectTimer) { + clearTimeout(this.wsReconnectTimer) + this.wsReconnectTimer = undefined + } + if (this.ws) { + this.ws.close(1000) + delete this.ws + } + } + private maybeReconnectWebSocket() { + if (this.wsReconnectTimer) { + clearTimeout(this.wsReconnectTimer) + } + this.wsReconnectTimer = setTimeout(() => { + this.startWebSocket() + }, 5000) + } + + private websocketMessageHandler(data: WebSocket.MessageEvent) { + this.log('info', 'Websocket message received') + + if (typeof data.data === 'string') { + this.log('debug', `data: ${data.data}`) + const event = JSON.parse(data.data) + if (isITimeEntryWebhookPayload(event)) { + const entry = event.payload + if (entry.stop === '' && event.metadata.action != 'deleted') { + this.log('info', `update time entry to ${entry.description}`) + this.setCurrentlyRunningTimeEntry(entry) + } else if (entry.id == this.currentEntry?.id) { + // only unset value if update is for current entry + this.log('info', 'stop time entry') + this.setCurrentlyRunningTimeEntry(undefined) + } else { + this.log('warn', `unhandled case for time entry ${JSON.stringify(entry)}`) + } + } else { + this.log('warn', `unhandled event ${data.data}`) + } + + return + } + this.log('warn', `unhandled websocket event '${JSON.stringify(data)}'`) + } + /** * Set variables to this time entry * @param entry running entry or undefined diff --git a/src/toggl-extend.ts b/src/toggl-extend.ts index 990b3a1..3fa2088 100644 --- a/src/toggl-extend.ts +++ b/src/toggl-extend.ts @@ -1,4 +1,4 @@ -import { Toggl } from 'toggl-track' +import { ITimeEntry, Toggl } from 'toggl-track' export interface IWorkspace { id: number @@ -9,3 +9,55 @@ export async function togglGetWorkspaces(toggl: Toggl): Promise { const resp: IWorkspace[] = await toggl.request('workspaces', {}) return resp } + +export interface IWebhookMetadata { + action: 'created' | 'updated' | 'deleted' + event_user_id: number + entity_id: number + model: 'time_entry' + workspace_id: number + project_id: number +} + +export interface ITimeEntryWebhookPayload { + event_id: number + created_at: string + creator_id: number + metadata: IWebhookMetadata + payload: ITimeEntry + subscription_id: number + timestamp: string + url_callback: string +} + +export function isITimeEntryWebhookPayload(event: unknown): event is ITimeEntryWebhookPayload { + return ( + typeof event === 'object' && + event != null && + 'metadata' in event && + typeof event.metadata === 'object' && + event.metadata !== null && + 'model' in event.metadata && + typeof event.metadata.model === 'string' && + event.metadata.model === 'time_entry' && + 'payload' in event && + typeof event.payload === 'object' && + event.payload !== null && + isITimeEntry(event.payload) + ) +} + +export function isITimeEntry(entry: unknown): entry is ITimeEntry { + return ( + typeof entry === 'object' && + entry != null && + 'id' in entry && + typeof entry.id === 'number' && + 'description' in entry && + typeof entry.description === 'string' && + 'project_id' in entry && + typeof entry.project_id === 'number' && + 'start' in entry && + typeof entry.start === 'string' + ) +}