WIP: Add websocket connection for streaming toggl updates
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
45
src/main.ts
45
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<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
143
src/toggl-stream.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
yarn.lock
16
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"
|
||||
|
||||
Reference in New Issue
Block a user