Compare commits
5 Commits
d95d12d324
...
mk/add_web
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d5a3c1e51 | |||
| 0a327b247c | |||
| 8cd7127af8 | |||
| b9060b683f | |||
| 34a2814c19 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
76
.github/workflows/node.yaml
vendored
Normal file
76
.github/workflows/node.yaml
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
name: Node CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags:
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 22.x
|
||||||
|
# This should match the version of Node.js you have defined in the manifest.json runtime field
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
- name: Prepare Environment
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
- name: Prepare Environment (For template repository)
|
||||||
|
# Only run this step if the repository is a template repository
|
||||||
|
# If you are using this in a module, you can remove this step
|
||||||
|
if: ${{ contains(github.repository, 'companion-module-template-') }}
|
||||||
|
run: |
|
||||||
|
# Perform an install to generate the lockfile
|
||||||
|
yarn install
|
||||||
|
env:
|
||||||
|
CI: false
|
||||||
|
- name: Prepare module
|
||||||
|
run: |
|
||||||
|
yarn install
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
- name: Build and check types
|
||||||
|
run: |
|
||||||
|
yarn build
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
- name: Run lint
|
||||||
|
run: |
|
||||||
|
yarn lint
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
# Uncomment this to enable running unit tests
|
||||||
|
# test:
|
||||||
|
# name: Test
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# timeout-minutes: 15
|
||||||
|
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
# - name: Use Node.js 22.x
|
||||||
|
# uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: 22.x
|
||||||
|
# - name: Prepare Environment
|
||||||
|
# run: |
|
||||||
|
# corepack enable
|
||||||
|
# yarn install
|
||||||
|
# env:
|
||||||
|
# CI: true
|
||||||
|
# - name: Run tests
|
||||||
|
# run: |
|
||||||
|
# yarn test
|
||||||
|
# env:
|
||||||
|
# CI: true
|
||||||
|
# - name: Send coverage
|
||||||
|
# uses: codecov/codecov-action@v5
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,7 +2,8 @@ node_modules/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/pkg
|
/pkg
|
||||||
/pkg.tgz
|
/*.tgz
|
||||||
/dist
|
/dist
|
||||||
DEBUG-*
|
DEBUG-*
|
||||||
/.yarn
|
/.yarn
|
||||||
|
/.vscode
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Bitfocus AS - Open Source
|
Copyright (c) 2022 Bitfocus AS - Open Source
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
# companion-module-toggl-track
|
# companion-module-toggl-track
|
||||||
|
|
||||||
See [HELP.md](./companion/HELP.md) and [LICENSE](./LICENSE)
|
See [HELP.md](./companion/HELP.md) and [LICENSE](./LICENSE)
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Executing a `yarn` command should perform all necessary steps to develop the module, if it does not then follow the steps below.
|
||||||
|
|
||||||
|
The module can be built once with `yarn build`. This should be enough to get the module to be loadable by companion.
|
||||||
|
|
||||||
|
While developing the module, by using `yarn dev` the compiler will be run in watch mode to recompile the files on change.
|
||||||
|
|||||||
@@ -74,3 +74,13 @@ Presets are available for **Start Timer** and **Stop Timer**.
|
|||||||
### Version 2.1.1
|
### Version 2.1.1
|
||||||
|
|
||||||
- Prevent module crash if user has no Clients
|
- 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)
|
||||||
|
|
||||||
|
### Version 2.1.3
|
||||||
|
|
||||||
|
- update dependencies (fix CVE-2025-58754)
|
||||||
|
- handle rate limiting of toggl
|
||||||
|
|||||||
32
package.json
32
package.json
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "toggl-track",
|
"name": "toggl-track",
|
||||||
"version": "2.1.1",
|
"version": "2.1.3",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "husky",
|
"postinstall": "husky",
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"package": "yarn run build && companion-module-build",
|
"package": "run build && companion-module-build",
|
||||||
"build": "rimraf dist && yarn run build:main",
|
"build": "rimraf dist && run build:main",
|
||||||
"build:main": "tsc -p tsconfig.build.json",
|
"build:main": "tsc -p tsconfig.build.json",
|
||||||
"dev": "tsc -p tsconfig.build.json --watch",
|
"dev": "tsc -p tsconfig.build.json --watch",
|
||||||
"lint:raw": "eslint",
|
"lint:raw": "eslint",
|
||||||
"lint": "yarn run lint:raw .",
|
"lint": "run lint:raw .",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"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"
|
||||||
@@ -22,23 +23,22 @@
|
|||||||
"node": "^22.14",
|
"node": "^22.14",
|
||||||
"yarn": "^4"
|
"yarn": "^4"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@companion-module/base": "~1.11.3",
|
"@companion-module/base": "~1.13.4",
|
||||||
"toggl-track": "^0.8.0",
|
"toggl-track": "https://github.com/krombel/toggl-track#v0.8.0-2",
|
||||||
"ws": "^8.18.2"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@companion-module/tools": "~2.3.0",
|
"@companion-module/tools": "~2.4.2",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.18.12",
|
||||||
"@types/ws": "^8",
|
"@types/ws": "^8",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.38.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^15.5.1",
|
"lint-staged": "^16.2.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.6.2",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.30.1"
|
"typescript-eslint": "^8.46.2"
|
||||||
},
|
},
|
||||||
"prettier": "@companion-module/tools/.prettierrc.json",
|
"prettier": "@companion-module/tools/.prettierrc.json",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -49,5 +49,5 @@
|
|||||||
"yarn lint:raw --fix"
|
"yarn lint:raw --fix"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1"
|
"packageManager": "yarn@4.10.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TogglTrack } from './main.js'
|
import { TogglTrack } from './main.js'
|
||||||
|
|
||||||
export default function (self: TogglTrack): void {
|
export function UpdateActions(self: TogglTrack): void {
|
||||||
self.setActionDefinitions({
|
self.setActionDefinitions({
|
||||||
startNewTimer: {
|
startNewTimer: {
|
||||||
name: 'Start New Timer',
|
name: 'Start New Timer',
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export interface ModuleConfig {
|
|||||||
alwaysStart: boolean
|
alwaysStart: boolean
|
||||||
startTimerPoller: boolean
|
startTimerPoller: boolean
|
||||||
timerPollerInterval: number
|
timerPollerInterval: number
|
||||||
startWebSocket: boolean
|
startTogglWebsocket: boolean
|
||||||
websocketSecret: string
|
wsToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetConfigFields(): SomeCompanionConfigField[] {
|
export function GetConfigFields(): SomeCompanionConfigField[] {
|
||||||
@@ -51,20 +51,30 @@ export function GetConfigFields(): SomeCompanionConfigField[] {
|
|||||||
default: 60,
|
default: 60,
|
||||||
min: 30,
|
min: 30,
|
||||||
max: 3600,
|
max: 3600,
|
||||||
|
isVisible: (opt) => {
|
||||||
|
return opt.startTimerPoller === true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
id: 'startWebSocket',
|
id: 'startTogglWebsocket',
|
||||||
label: 'start Websocket (krombel testing)',
|
label: 'EXPERIMENTAL: Use background websocket connection for updates',
|
||||||
width: 12,
|
width: 12,
|
||||||
default: false,
|
default: false,
|
||||||
|
isVisible: (opt) => {
|
||||||
|
return !opt.startTimerPoller
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'textinput',
|
type: 'textinput',
|
||||||
id: 'websocketSecret',
|
id: 'wsToken',
|
||||||
label: 'secret to authenticate against Websocket server (krombel testing)',
|
label: 'Personal API Token from your Toggl user profile (required)',
|
||||||
width: 12,
|
width: 12,
|
||||||
default: 'secret',
|
required: true,
|
||||||
|
default: '',
|
||||||
|
isVisible: (opt) => {
|
||||||
|
return !opt.startTimerPoller && opt.startTogglWebsocket === true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
219
src/main.ts
219
src/main.ts
@@ -1,22 +1,23 @@
|
|||||||
// toggltrack module
|
// toggltrack module
|
||||||
// Peter Daniel, Matthias Kesler
|
// 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 { GetConfigFields, type ModuleConfig } from './config.js'
|
||||||
import UpdateActions from './actions.js'
|
import { UpdatePresets } from './presets.js'
|
||||||
import UpdatePresets from './presets.js'
|
import { UpdateVariableDefinitions } from './variables.js'
|
||||||
import UpdateVariableDefinitions from './variables.js'
|
import { UpgradeScripts } from './upgrades.js'
|
||||||
import UpgradeScripts from './upgrades.js'
|
import { UpdateActions } from './actions.js'
|
||||||
import { UpdateFeedbacks } from './feedbacks.js'
|
import { UpdateFeedbacks } from './feedbacks.js'
|
||||||
import { Toggl, ITimeEntry, IWorkspaceProject, IClient } from 'toggl-track'
|
import { Toggl, ITimeEntry, IWorkspaceProject, IClient, isRatelimitError } from 'toggl-track'
|
||||||
import { isITimeEntryWebhookPayload, togglGetWorkspaces } from './toggl-extend.js'
|
import { togglGetWorkspaces } from './toggl-extend.js'
|
||||||
import { timecodeSince } from './utils.js'
|
import { timecodeSince } from './utils.js'
|
||||||
import WebSocket from 'ws'
|
import { TogglStream } from './toggl-stream.js'
|
||||||
|
|
||||||
export class TogglTrack extends InstanceBase<ModuleConfig> {
|
export class TogglTrack extends InstanceBase<ModuleConfig> {
|
||||||
config!: ModuleConfig // Setup in init()
|
config!: ModuleConfig // Setup in init()
|
||||||
|
|
||||||
toggl?: Toggl
|
toggl?: Toggl
|
||||||
|
stream?: TogglStream
|
||||||
|
|
||||||
workspaceId?: number // current active workspace id
|
workspaceId?: number // current active workspace id
|
||||||
workspaceName: string = '' // name of workspace
|
workspaceName: string = '' // name of workspace
|
||||||
@@ -24,33 +25,13 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
clients?: { id: number; label: string }[]
|
clients?: { id: number; label: string }[]
|
||||||
currentEntry?: ITimeEntry
|
currentEntry?: ITimeEntry
|
||||||
|
|
||||||
intervalId?: NodeJS.Timeout
|
intervalId?: NodeJS.Timeout // used for time entry poller and to postpone init on ratelimit
|
||||||
currentTimerUpdaterIntervalId?: NodeJS.Timeout
|
currentTimerUpdaterIntervalId?: NodeJS.Timeout // used to update the timer duration variable every second
|
||||||
wsReconnectTimer?: NodeJS.Timeout
|
|
||||||
|
|
||||||
ws?: WebSocket
|
|
||||||
|
|
||||||
constructor(internal: unknown) {
|
constructor(internal: unknown) {
|
||||||
super(internal)
|
super(internal)
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfigFields(): SomeCompanionConfigField[] {
|
|
||||||
return GetConfigFields()
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
|
||||||
this.log('info', 'destroy ' + this.id)
|
|
||||||
if (this.config.startTimerPoller) {
|
|
||||||
this.stopTimeEntryPoller()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.startWebSocket) {
|
|
||||||
this.stopWebSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInterval(this.currentTimerUpdaterIntervalId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(config: ModuleConfig): Promise<void> {
|
async init(config: ModuleConfig): Promise<void> {
|
||||||
this.log('info', '--- init toggltrack ' + this.id + ' ---')
|
this.log('info', '--- init toggltrack ' + this.id + ' ---')
|
||||||
|
|
||||||
@@ -60,6 +41,14 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.updatePresets()
|
this.updatePresets()
|
||||||
|
|
||||||
await this.initToggleConnection()
|
await this.initToggleConnection()
|
||||||
|
if (this.toggl && this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Retry init in ' + this.toggl.getRateLimitedUntil() + ' seconds')
|
||||||
|
this.intervalId = setTimeout(() => {
|
||||||
|
void this.init(this.config)
|
||||||
|
}, this.toggl.getRateLimitedUntil() * 1000)
|
||||||
|
// skip further init until ratelimit is over
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await this.loadStaticData()
|
await this.loadStaticData()
|
||||||
|
|
||||||
@@ -74,8 +63,22 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
if (this.config.startTimerPoller) {
|
if (this.config.startTimerPoller) {
|
||||||
this.startTimeEntryPoller()
|
this.startTimeEntryPoller()
|
||||||
}
|
}
|
||||||
if (this.config.startWebSocket) {
|
|
||||||
this.startWebSocket()
|
if (this.config.startTogglWebsocket) {
|
||||||
|
await this.startToggleStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When module gets deleted
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
this.log('debug', 'destroy ' + this.id)
|
||||||
|
if (this.config.startTimerPoller) {
|
||||||
|
this.stopTimeEntryPoller()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(this.currentTimerUpdaterIntervalId)
|
||||||
|
if (this.config.startTogglWebsocket && this.stream) {
|
||||||
|
await this.stream.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +88,8 @@ 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
|
|
||||||
|
|
||||||
|
const oldConfig = this.config
|
||||||
this.config = config
|
this.config = config
|
||||||
|
|
||||||
if (apiTokenChanged) {
|
if (apiTokenChanged) {
|
||||||
@@ -99,6 +102,19 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
await this.loadStaticData()
|
await this.loadStaticData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.toggl && this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Retry init in ' + this.toggl.getRateLimitedUntil() + ' seconds')
|
||||||
|
this.config = oldConfig // revert to old config until ratelimit is over
|
||||||
|
this.intervalId = setTimeout(() => {
|
||||||
|
// this harms the linter (handle unawaited promise in an non-async context)
|
||||||
|
void (async () => {
|
||||||
|
await this.configUpdated(config)
|
||||||
|
})()
|
||||||
|
}, this.toggl.getRateLimitedUntil() * 1000)
|
||||||
|
// skip further init until ratelimit is over
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (timeEntryPollerChanged) {
|
if (timeEntryPollerChanged) {
|
||||||
if (this.config.startTimerPoller) {
|
if (this.config.startTimerPoller) {
|
||||||
this.startTimeEntryPoller()
|
this.startTimeEntryPoller()
|
||||||
@@ -106,11 +122,12 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.stopTimeEntryPoller()
|
this.stopTimeEntryPoller()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (webSocketChanged) {
|
if (this.config.startTogglWebsocket) {
|
||||||
if (this.config.startWebSocket) {
|
await this.startToggleStream()
|
||||||
this.startWebSocket()
|
|
||||||
} else {
|
} else {
|
||||||
this.stopWebSocket()
|
if (this.stream) {
|
||||||
|
await this.stream.destroy()
|
||||||
|
delete this.stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +137,11 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.updateStatus(InstanceStatus.Ok)
|
this.updateStatus(InstanceStatus.Ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConfigFields(): SomeCompanionConfigField[] {
|
||||||
|
return GetConfigFields()
|
||||||
|
}
|
||||||
|
|
||||||
updateVariables(): void {
|
updateVariables(): void {
|
||||||
this.log('error', 'updateVariables not implemented')
|
this.log('error', 'updateVariables not implemented')
|
||||||
//throw new Error('Method not implemented.')
|
//throw new Error('Method not implemented.')
|
||||||
@@ -127,6 +149,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
updateActions(): void {
|
updateActions(): void {
|
||||||
UpdateActions(this)
|
UpdateActions(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFeedbacks(): void {
|
updateFeedbacks(): void {
|
||||||
UpdateFeedbacks(this)
|
UpdateFeedbacks(this)
|
||||||
}
|
}
|
||||||
@@ -147,11 +170,23 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
const resp = await this.toggl.me.logged()
|
const resp = await this.toggl.me.logged()
|
||||||
if (resp !== '') {
|
if (resp !== '') {
|
||||||
this.log('warn', 'error during token check: ' + resp)
|
this.log('warn', 'error during token check: ' + resp)
|
||||||
this.toggl = undefined
|
this.toggl = undefined
|
||||||
this.updateStatus(InstanceStatus.AuthenticationFailure, resp)
|
this.updateStatus(InstanceStatus.AuthenticationFailure, resp as string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.log('warn', 'error during token check: ' + (e as Error).message)
|
||||||
|
if (isRatelimitError(e)) {
|
||||||
|
this.log('warn', 'ratelimited. Will reset in' + e.resetAfterSeconds)
|
||||||
|
this.updateStatus(InstanceStatus.ConnectionFailure, `Ratelimited. Retry after ${e.resetAfterSeconds} seconds`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.toggl = undefined
|
||||||
|
this.updateStatus(InstanceStatus.AuthenticationFailure, (e as Error).message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,77 +207,33 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
clearInterval(this.intervalId)
|
clearInterval(this.intervalId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private startWebSocket(): void {
|
private async startToggleStream(): Promise<void> {
|
||||||
this.log('info', 'start websocket')
|
if (this.stream) {
|
||||||
|
await this.stream.destroy()
|
||||||
this.updateStatus(InstanceStatus.Connecting)
|
delete this.stream
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close(1000)
|
|
||||||
delete this.ws
|
|
||||||
}
|
}
|
||||||
|
this.stream = new TogglStream({
|
||||||
this.ws = new WebSocket('wss://doh.krombel.de/toggl-websocket')
|
apiToken: this.config.wsToken!,
|
||||||
this.ws.addEventListener('open', () => {
|
reconnect: true,
|
||||||
this.updateStatus(InstanceStatus.Ok)
|
log: (level: LogLevel, message: string) => {
|
||||||
this.ws?.send(this.config.websocketSecret)
|
this.log(level, 'ws: ' + message)
|
||||||
})
|
},
|
||||||
this.ws.addEventListener('close', (code) => {
|
onTimeEntryCreated: (e: ITimeEntry) => {
|
||||||
this.log('debug', `Connection closed with code ${code.code}`)
|
if (!e.stop) {
|
||||||
this.updateStatus(InstanceStatus.Disconnected, `WS-Connection closed with code ${code.code}`)
|
this.setCurrentlyRunningTimeEntry(e)
|
||||||
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 {
|
onTimeEntryUpdate: (e: ITimeEntry) => {
|
||||||
this.log('info', 'stop websocket')
|
if (e.id == this.currentEntry?.id && e.stop) {
|
||||||
|
// currently running timer got a stop entry
|
||||||
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)
|
this.setCurrentlyRunningTimeEntry(undefined)
|
||||||
} else {
|
} else {
|
||||||
this.log('warn', `unhandled case for time entry ${JSON.stringify(entry)}`)
|
// store update on this time entry (e.g. rename)
|
||||||
|
this.setCurrentlyRunningTimeEntry(e)
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
this.log('warn', `unhandled event ${data.data}`)
|
})
|
||||||
}
|
this.stream.start()
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.log('warn', `unhandled websocket event '${JSON.stringify(data)}'`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -297,6 +288,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.log('warn', 'Not authorized')
|
this.log('warn', 'Not authorized')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
if (this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Skipping get current timer')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const entry: ITimeEntry = await this.toggl.timeEntry.current()
|
const entry: ITimeEntry = await this.toggl.timeEntry.current()
|
||||||
this.log('debug', 'response for timer id ' + JSON.stringify(entry))
|
this.log('debug', 'response for timer id ' + JSON.stringify(entry))
|
||||||
@@ -318,6 +313,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.log('warn', 'loadStaticData: toggle connection not set up')
|
this.log('warn', 'loadStaticData: toggle connection not set up')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Skipping get current timer')
|
||||||
|
return
|
||||||
|
}
|
||||||
await this.getWorkspace()
|
await this.getWorkspace()
|
||||||
await this.getProjects()
|
await this.getProjects()
|
||||||
await this.getClients()
|
await this.getClients()
|
||||||
@@ -329,6 +328,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.log('warn', 'Not authorized')
|
this.log('warn', 'Not authorized')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Skipping get current timer')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// reset
|
// reset
|
||||||
this.workspaceId = undefined
|
this.workspaceId = undefined
|
||||||
@@ -454,6 +457,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.log('error', 'toggle not initialized. Do not start time')
|
this.log('error', 'toggle not initialized. Do not start time')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Skipping get current timer')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const currentId = await this.getCurrentTimer()
|
const currentId = await this.getCurrentTimer()
|
||||||
let newEntry: ITimeEntry
|
let newEntry: ITimeEntry
|
||||||
@@ -481,6 +488,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
|
|||||||
this.log('error', 'toggle not initialized. Do not start time')
|
this.log('error', 'toggle not initialized. Do not start time')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (this.toggl.getRateLimitedUntil() > 0) {
|
||||||
|
this.log('warn', 'Ratelimited. Skipping get current timer')
|
||||||
|
return
|
||||||
|
}
|
||||||
const currentId = await this.getCurrentTimer()
|
const currentId = await this.getCurrentTimer()
|
||||||
this.log('info', 'Trying to stop current timer id: ' + currentId)
|
this.log('info', 'Trying to stop current timer id: ' + currentId)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { combineRgb } from '@companion-module/base'
|
import { combineRgb } from '@companion-module/base'
|
||||||
import { TogglTrack } from './main.js'
|
import { TogglTrack } from './main.js'
|
||||||
|
|
||||||
export default function (self: TogglTrack): void {
|
export function UpdatePresets(self: TogglTrack): void {
|
||||||
self.setPresetDefinitions({
|
self.setPresetDefinitions({
|
||||||
Start: {
|
Start: {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ITimeEntry, Toggl } from 'toggl-track'
|
import { Toggl } from 'toggl-track'
|
||||||
|
|
||||||
export interface IWorkspace {
|
export interface IWorkspace {
|
||||||
id: number
|
id: number
|
||||||
@@ -9,55 +9,3 @@ 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'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
export default [
|
import type { CompanionStaticUpgradeScript } from '@companion-module/base'
|
||||||
|
import type { ModuleConfig } from './config.js'
|
||||||
|
|
||||||
|
export const UpgradeScripts: CompanionStaticUpgradeScript<ModuleConfig>[] = [
|
||||||
/*
|
/*
|
||||||
* Place your upgrade scripts here
|
* Place your upgrade scripts here
|
||||||
* Remember that once it has been added it cannot be removed!
|
* Remember that once it has been added it cannot be removed!
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TogglTrack } from './main.js'
|
import { TogglTrack } from './main.js'
|
||||||
|
|
||||||
export default function (self: TogglTrack): void {
|
export function UpdateVariableDefinitions(self: TogglTrack): void {
|
||||||
self.setVariableDefinitions([
|
self.setVariableDefinitions([
|
||||||
{
|
{
|
||||||
name: 'Workspace',
|
name: 'Workspace',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@companion-module/tools/tsconfig/node18/recommended",
|
"extends": "@companion-module/tools/tsconfig/node22/recommended",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"],
|
"exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules/**"],
|
"exclude": ["node_modules/**"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["jest", "node"]
|
"types": ["node" /* , "jest" ] // uncomment this if using jest */]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user