Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ STABILITY_API_KEY=
OPENAI_API_KEY=
TELEMETREE_API_KEY=
TELEMETREE_PROJECT_ID=
UMAMI_WEBSITE_ID=

# --- v3 economy (all optional, defaults shown) --------------------------------
# Price of one NFT-image generation try, in $CUBE.
Expand Down
1 change: 1 addition & 0 deletions config/deploy.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ env:
clear:
BOT_NAME: cubeworldsbot
WEB_APP_URL: https://staging.cubeworlds.club
UMAMI_WEBSITE_ID: "" # staging must not pollute the prod Umami website
# MONGO / BOT_TOKEN / BOT_WEBHOOK / BOT_WEBHOOK_SECRET come from
# .kamal/secrets.staging (overrides secrets-common).
1 change: 1 addition & 0 deletions config/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ env:
PINATA_GATEWAY: https://tan-above-limpet-868.mypinata.cloud
TESTNET: "false" # mainnet in prod (config default is true — must override)
TELEMETREE_PROJECT_ID: eda52fec-d310-43b5-a401-fb0d56e59708
UMAMI_WEBSITE_ID: 247a76a0-5aff-4cb6-b5d0-de808d2c05f8 # analytics.nextgensoft.co website "cube-worlds-bot" (public id)
secret: # decrypted from SOPS via .kamal/secrets (Phase B)
- MONGO # full URI incl. app-user creds (axveer mongo requires auth)
- BOT_TOKEN
Expand Down
2 changes: 2 additions & 0 deletions src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { errorHandler } from '#root/bot/handlers/index'
import { queueMenu } from '#root/bot/keyboards/queue-menu'
import attachUser from '#root/bot/middlewares/attach-user'
import { updateLogger } from '#root/bot/middlewares/index'
import { configureUmami } from '#root/common/helpers/umami'
import { i18n } from '#root/common/i18n'
import { config } from '#root/config'
import { logger } from '#root/logger'
Expand All @@ -44,6 +45,7 @@ export function createBot(token: string, options: Options) {
ContextConstructor: createContextConstructor({ logger }),
})
const protectedBot = bot.errorBoundary(errorHandler)
configureUmami(config.UMAMI_WEBSITE_ID)

// Middlewares
bot.api.config.use(autoRetry())
Expand Down
2 changes: 2 additions & 0 deletions src/common/helpers/logging.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Update } from '@grammyjs/types'
import type { MiddlewareFn } from 'grammy'
import type { Context } from '#root/bot/context'
import { trackEvent } from '#root/common/helpers/umami'

export function getUpdateInfo(ctx: Context): Omit<Update, 'update_id'> {
const { update_id, ...update } = ctx.update
Expand All @@ -13,6 +14,7 @@ export function logHandle(id: string): MiddlewareFn<Context> {
msg: `handle ${id}`,
...(id.startsWith('unhandled') ? { update: getUpdateInfo(ctx) } : {}),
})
trackEvent(id, ctx.from?.id)

return next()
}
Expand Down
45 changes: 45 additions & 0 deletions src/common/helpers/umami.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* eslint-disable test/no-import-node-test */
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { configureUmami, trackEvent, umamiPayload, umamiUserId } from '#root/common/helpers/umami'

function captureFetch() {
const calls: { url: string, init: RequestInit }[] = []
const original = globalThis.fetch
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} })
return new Response('ok')
}) as typeof fetch
return { calls, restore: () => { globalThis.fetch = original } }
}

test('trackEvent is a no-op until a website id is configured', () => {
const { calls, restore } = captureFetch()
try {
configureUmami('')
trackEvent('command-start', 42)
assert.equal(calls.length, 0)
}
finally {
restore()
}
})

test('trackEvent posts the event with a browser UA and a hashed user id', () => {
const { calls, restore } = captureFetch()
try {
configureUmami('site-1')
trackEvent('command-start', 42)
assert.equal(calls.length, 1)
assert.equal(calls[0].url, 'https://analytics.nextgensoft.co/api/send')
const headers = calls[0].init.headers as Record<string, string>
assert.match(headers['user-agent'], /^Mozilla\/5\.0/)
assert.deepEqual(JSON.parse(calls[0].init.body as string), umamiPayload('command-start', 42))
assert.equal(umamiPayload('x', 42).payload.id, umamiUserId(42))
assert.notEqual(umamiUserId(42), '42')
}
finally {
configureUmami('')
restore()
}
})
44 changes: 44 additions & 0 deletions src/common/helpers/umami.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto'

// Umami (self-hosted, https://analytics.nextgensoft.co) server-side events —
// infra docs/runbook-analytics.md. Website id is public; empty ⇒ tracking off.
const UMAMI_SEND = 'https://analytics.nextgensoft.co/api/send'
// Umami runs `isbot` on the User-Agent and answers 200 while silently dropping
// anything that matches (curl, node, *bot*) — a browser-looking UA is load-bearing.
const UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36'

let websiteId = ''

export function configureUmami(id: string): void {
websiteId = id
}

// Stable, non-reversible per-user id for Umami's `id` field (no raw Telegram ids leave the box).
export function umamiUserId(telegramId: number): string {
return createHash('sha256').update(String(telegramId)).digest('hex').slice(0, 16)
}

export function umamiPayload(name: string, telegramId?: number) {
return {
type: 'event',
payload: {
website: websiteId,
hostname: 'cubeworlds.club',
url: `/${name}`,
name,
...(telegramId ? { id: umamiUserId(telegramId) } : {}),
},
}
}

// Fire-and-forget: never awaited by a handler, never throws, 2 s cap. No-op until configured.
export function trackEvent(name: string, telegramId?: number): void {
if (!websiteId) return
fetch(UMAMI_SEND, {
method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': UA },
body: JSON.stringify(umamiPayload(name, telegramId)),
signal: AbortSignal.timeout(2000),
}).catch(() => {})
}
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ function createConfigFromEnvironment(environment: NodeJS.ProcessEnv) {
OPENAI_API_KEY: z.string(),
TELEMETREE_API_KEY: z.string(),
TELEMETREE_PROJECT_ID: z.string(),
// Umami website id (public). Empty ⇒ no analytics events (dev, staging, tests).
UMAMI_WEBSITE_ID: z.string().default(''),
// Price of one NFT-image generation try, in $CUBE (votes). Tunable.
GENERATION_TRY_COST_VOTES: z.number().int().default(100),
// Telegram Stars top-up: $CUBE (votes) credited per 1 Star. Tunable.
Expand Down
Loading