WIP: Add websocket connection for streaming toggl updates
Some checks failed
Companion Module Checks / Check module (push) Failing after 0s
Node CI / Lint (push) Successful in 1m14s

This commit is contained in:
2025-01-20 14:40:08 +01:00
parent 0a327b247c
commit 1d5a3c1e51
5 changed files with 231 additions and 2 deletions

View File

@@ -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",

View File

@@ -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
},
},
]
}

View File

@@ -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<ModuleConfig> {
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<ModuleConfig> {
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<ModuleConfig> {
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<ModuleConfig> {
clearInterval(this.intervalId)
}
private async startToggleStream(): Promise<void> {
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

143
src/toggl-stream.ts Normal file
View File

@@ -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<void> {
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
}
}
}
}
}

View File

@@ -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"