Compare commits

...

3 Commits

Author SHA1 Message Date
d95d12d324 add websocket connection to webhook-proxy
Some checks failed
Companion Module Checks / Check module (push) Failing after 0s
CI / build (push) Successful in 6s
2025-09-29 14:40:09 +02:00
084b27f945 update deps to current state of module template; update runtime to node22 2025-09-29 14:38:30 +02:00
bryce
2f9fa1cb0b fix: module crash when there are no clients (#8)
* fix: module crashing with no clients

* chore: version bump

* chore: format
2025-09-14 14:16:26 -07:00
9 changed files with 3573 additions and 25 deletions

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
lint-staged

View File

@@ -64,9 +64,13 @@ Presets are available for **Start Timer** and **Stop Timer**.
### Version 2.1.0
- rewrite module in typescript
- use module toggl-track instead of implementing api on our own
- add status reports for some failure cases in connections dashboard
- add configurable time entry poller
- add feedback for currently running project and client
- update timerDuration to contain the correct duration formatted as time string
- Rewrite module in typescript
- Use module toggl-track instead of implementing api on our own
- Add status reports for some failure cases in connections dashboard
- Add configurable time entry poller
- Add feedback for currently running project and client
- Update timerDuration to contain the correct duration formatted as time string
### Version 2.1.1
- Prevent module crash if user has no Clients

View File

@@ -3,7 +3,7 @@
"name": "toggl-track",
"shortname": "toggl-track",
"description": "Companion module for toggltrack timers",
"version": "2.0.1",
"version": "0.0.0",
"license": "MIT",
"repository": "git+https://github.com/bitfocus/companion-module-toggl-track.git",
"bugs": "https://github.com/bitfocus/companion-module-toggl-track/issues",
@@ -17,7 +17,7 @@
],
"legacyIds": [],
"runtime": {
"type": "node18",
"type": "node22",
"api": "nodejs-ipc",
"apiVersion": "0.0.0",
"entrypoint": "../dist/main.js"

View File

@@ -1,9 +1,10 @@
{
"name": "toggl-track",
"version": "2.1.0",
"version": "2.1.1",
"main": "dist/main.js",
"type": "module",
"scripts": {
"postinstall": "husky",
"format": "prettier -w .",
"package": "yarn run build && companion-module-build",
"build": "rimraf dist && yarn run build:main",
@@ -17,19 +18,27 @@
"type": "git",
"url": "git+https://github.com/bitfocus/companion-module-toggl-track.git"
},
"engines": {
"node": "^22.14",
"yarn": "^4"
},
"license": "MIT",
"dependencies": {
"@companion-module/base": "~1.11",
"toggl-track": "^0.8.0"
"@companion-module/base": "~1.11.3",
"toggl-track": "^0.8.0",
"ws": "^8.18.2"
},
"devDependencies": {
"@companion-module/tools": "~2.1.1",
"@types/node": "^22.10.2",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"rimraf": "^5.0.10",
"typescript": "~5.5.4",
"typescript-eslint": "^8.18.1"
"@companion-module/tools": "~2.3.0",
"@types/node": "^22.14.1",
"@types/ws": "^8",
"eslint": "^9.24.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1"
},
"prettier": "@companion-module/tools/.prettierrc.json",
"lint-staged": {
@@ -39,5 +48,6 @@
"*.{ts,tsx,js,jsx}": [
"yarn lint:raw --fix"
]
}
},
"packageManager": "yarn@4.9.1"
}

View File

@@ -5,6 +5,9 @@ export interface ModuleConfig {
workspaceName: string
alwaysStart: boolean
startTimerPoller: boolean
timerPollerInterval: number
startWebSocket: boolean
websocketSecret: string
}
export function GetConfigFields(): SomeCompanionConfigField[] {
@@ -36,9 +39,32 @@ export function GetConfigFields(): SomeCompanionConfigField[] {
{
type: 'checkbox',
id: 'startTimerPoller',
label: 'Poll for current time entry every 30 seconds',
label: 'Poll for current time entry every n seconds',
width: 12,
default: false,
},
{
type: 'number',
id: 'timerPollerInterval',
label: 'Poll interval in seconds',
width: 12,
default: 60,
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',
},
]
}

View File

@@ -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<ModuleConfig> {
config!: ModuleConfig // Setup in init()
@@ -25,6 +26,9 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
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<ModuleConfig> {
this.stopTimeEntryPoller()
}
if (this.config.startWebSocket) {
this.stopWebSocket()
}
clearInterval(this.currentTimerUpdaterIntervalId)
}
@@ -66,6 +74,9 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
if (this.config.startTimerPoller) {
this.startTimeEntryPoller()
}
if (this.config.startWebSocket) {
this.startWebSocket()
}
}
async configUpdated(config: ModuleConfig): Promise<void> {
@@ -74,6 +85,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
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<ModuleConfig> {
this.stopTimeEntryPoller()
}
}
if (webSocketChanged) {
if (this.config.startWebSocket) {
this.startWebSocket()
} else {
this.stopWebSocket()
}
}
this.updateActions()
//this.updateVariables()
@@ -146,13 +165,86 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
void (async () => {
await this.getCurrentTimer()
})()
}, 30 * 1000)
}, this.config.timerPollerInterval * 1000)
}
private stopTimeEntryPoller(): void {
this.log('info', 'Stopping TimeEntry-Poller')
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
@@ -167,7 +259,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
timerDuration: timecodeSince(new Date(entry.start)),
timerProject: project?.label,
timerProjectID: entry.project_id,
timerClient: this.clients!.find((c) => c.id == project?.clientID)?.label,
timerClient: this.clients?.find((c) => c.id == project?.clientID)?.label ?? 'None',
timerClientID: project?.clientID,
})

View File

@@ -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<IWorkspace[]> {
const resp: IWorkspace[] = await toggl.request<IWorkspace[]>('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'
)
}

3363
yarn.lock Normal file

File diff suppressed because it is too large Load Diff