Compare commits

...

4 Commits

Author SHA1 Message Date
a7821a7cb5 add websocket connection to webhook-proxy
Some checks failed
Companion Module Checks / Check module (push) Failing after 0s
CI / build (push) Successful in 5s
2025-09-29 15:01:05 +02:00
b9060b683f increase package version 2025-09-29 14:57:02 +02:00
34a2814c19 update deps to current state of module template; update runtime to node22 2025-09-29 14:54:12 +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 3578 additions and 25 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,6 +1,6 @@
# These are supported funding model platforms # These are supported funding model platforms
github: [daniep01,krombel] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: [daniep01, krombel] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username ko_fi: # Replace with a single Ko-fi username

1
.husky/pre-commit Normal file
View File

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

View File

@@ -64,9 +64,18 @@ Presets are available for **Start Timer** and **Stop Timer**.
### Version 2.1.0 ### Version 2.1.0
- rewrite module in typescript - Rewrite module in typescript
- use module toggl-track instead of implementing api on our own - Use module toggl-track instead of implementing api on our own
- add status reports for some failure cases in connections dashboard - Add status reports for some failure cases in connections dashboard
- add configurable time entry poller - Add configurable time entry poller
- add feedback for currently running project and client - Add feedback for currently running project and client
- update timerDuration to contain the correct duration formatted as time string - Update timerDuration to contain the correct duration formatted as time string
### Version 2.1.1
- Prevent module crash if user has no Clients
### Version 2.1.2
- Update node runtime to node22
- make polling interval configurable as toggl is updating their [API usage limits](https://support.toggl.com/en/articles/11484112-api-webhook-limits)

View File

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

View File

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

View File

@@ -5,6 +5,9 @@ export interface ModuleConfig {
workspaceName: string workspaceName: string
alwaysStart: boolean alwaysStart: boolean
startTimerPoller: boolean startTimerPoller: boolean
timerPollerInterval: number
startWebSocket: boolean
websocketSecret: string
} }
export function GetConfigFields(): SomeCompanionConfigField[] { export function GetConfigFields(): SomeCompanionConfigField[] {
@@ -36,9 +39,32 @@ export function GetConfigFields(): SomeCompanionConfigField[] {
{ {
type: 'checkbox', type: 'checkbox',
id: 'startTimerPoller', id: 'startTimerPoller',
label: 'Poll for current time entry every 30 seconds', label: 'Poll for current time entry every n seconds',
width: 12, width: 12,
default: false, 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 UpgradeScripts from './upgrades.js'
import { UpdateFeedbacks } from './feedbacks.js' import { UpdateFeedbacks } from './feedbacks.js'
import { Toggl, ITimeEntry, IWorkspaceProject, IClient } from 'toggl-track' 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 { timecodeSince } from './utils.js'
import WebSocket from 'ws'
export class TogglTrack extends InstanceBase<ModuleConfig> { export class TogglTrack extends InstanceBase<ModuleConfig> {
config!: ModuleConfig // Setup in init() config!: ModuleConfig // Setup in init()
@@ -25,6 +26,9 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
intervalId?: NodeJS.Timeout intervalId?: NodeJS.Timeout
currentTimerUpdaterIntervalId?: NodeJS.Timeout currentTimerUpdaterIntervalId?: NodeJS.Timeout
wsReconnectTimer?: NodeJS.Timeout
ws?: WebSocket
constructor(internal: unknown) { constructor(internal: unknown) {
super(internal) super(internal)
@@ -40,6 +44,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.stopTimeEntryPoller() this.stopTimeEntryPoller()
} }
if (this.config.startWebSocket) {
this.stopWebSocket()
}
clearInterval(this.currentTimerUpdaterIntervalId) clearInterval(this.currentTimerUpdaterIntervalId)
} }
@@ -66,6 +74,9 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
if (this.config.startTimerPoller) { if (this.config.startTimerPoller) {
this.startTimeEntryPoller() this.startTimeEntryPoller()
} }
if (this.config.startWebSocket) {
this.startWebSocket()
}
} }
async configUpdated(config: ModuleConfig): Promise<void> { 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 apiTokenChanged: boolean = this.config.apiToken != config.apiToken
const workSpaceDefaultChanged: boolean = this.config.workspaceName != config.workspaceName const workSpaceDefaultChanged: boolean = this.config.workspaceName != config.workspaceName
const timeEntryPollerChanged: boolean = this.config.startTimerPoller != config.startTimerPoller const timeEntryPollerChanged: boolean = this.config.startTimerPoller != config.startTimerPoller
const webSocketChanged: boolean = this.config.startWebSocket != config.startWebSocket
this.config = config this.config = config
@@ -94,6 +106,13 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.stopTimeEntryPoller() this.stopTimeEntryPoller()
} }
} }
if (webSocketChanged) {
if (this.config.startWebSocket) {
this.startWebSocket()
} else {
this.stopWebSocket()
}
}
this.updateActions() this.updateActions()
//this.updateVariables() //this.updateVariables()
@@ -146,13 +165,86 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
void (async () => { void (async () => {
await this.getCurrentTimer() await this.getCurrentTimer()
})() })()
}, 30 * 1000) }, this.config.timerPollerInterval * 1000)
} }
private stopTimeEntryPoller(): void { private stopTimeEntryPoller(): void {
this.log('info', 'Stopping TimeEntry-Poller') this.log('info', 'Stopping TimeEntry-Poller')
clearInterval(this.intervalId) 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 * Set variables to this time entry
* @param entry running entry or undefined * @param entry running entry or undefined
@@ -167,7 +259,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
timerDuration: timecodeSince(new Date(entry.start)), timerDuration: timecodeSince(new Date(entry.start)),
timerProject: project?.label, timerProject: project?.label,
timerProjectID: entry.project_id, 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, timerClientID: project?.clientID,
}) })

View File

@@ -1,4 +1,4 @@
import { Toggl } from 'toggl-track' import { ITimeEntry, Toggl } from 'toggl-track'
export interface IWorkspace { export interface IWorkspace {
id: number id: number
@@ -9,3 +9,55 @@ export async function togglGetWorkspaces(toggl: Toggl): Promise<IWorkspace[]> {
const resp: IWorkspace[] = await toggl.request<IWorkspace[]>('workspaces', {}) const resp: IWorkspace[] = await toggl.request<IWorkspace[]>('workspaces', {})
return resp 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