fix(notifications): изолировать уведомления по space и чинить отправку в Telegram

- «Истекает оплата» строится только по VPS текущего space
- ключ notification_state разделён по space: второй space больше не
  падает на PRIMARY KEY и не получает дубль каждый тик
- таймаут 10 с для Telegram и webhook
- текст от хостеров экранируется для parse_mode=HTML
- сообщения длиннее 4096 символов обрезаются по строке
- повторное падение того же VPS снова уведомляет
- ошибки по отдельным чатам пишутся в журнал при частичной доставке
- удалён неиспользуемый sendWebhook/notifyWebhook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-06 22:45:45 +07:00
1 parent 80af888cdb
commit f38694da63
10 files changed
+167 -56

No files matched your search

+14 -4
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { and, desc, eq } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { getCurrentSpaceId, MAIN_SPACE_ID } from '../space-context.js'
export type NotificationChannel = 'telegram' | 'webhook'
export type NotificationLogStatus = 'sent' | 'failed' | 'skipped'
@@ -42,6 +42,14 @@ function toLogDto(row: typeof schema.notificationLog.$inferSelect): Notification
}
}
/**
* notification_state.key is the table's sole PRIMARY KEY, so two spaces with the same event key collided
* (insert failed after sending → re-sent every tick). Non-main spaces get a prefixed key; main keeps old rows.
*/
function stateKeyForSpace(key: string, spaceId: string): string {
return spaceId === MAIN_SPACE_ID ? key : `${spaceId}::${key}`
}
export const notificationRepository = {
listRecent(limit = 50): NotificationLogRow[] {
const spaceId = getCurrentSpaceId()
@@ -79,8 +87,9 @@ export const notificationRepository = {
.run()
},
getState(key: string) {
getState(rawKey: string) {
const spaceId = getCurrentSpaceId()
const key = stateKeyForSpace(rawKey, spaceId)
return getDb()
.select()
.from(schema.notificationState)
@@ -93,10 +102,11 @@ export const notificationRepository = {
.get()
},
upsertState(key: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
upsertState(rawKey: string, patch: { lastFingerprint?: string; lastSentAt?: string; lastStatus?: string }) {
const db = getDb()
const spaceId = getCurrentSpaceId()
const existing = this.getState(key)
const existing = this.getState(rawKey)
const key = stateKeyForSpace(rawKey, spaceId)
const values = {
key,
spaceId,
+15
View File
@@ -65,3 +65,18 @@ describe('ensureRuntimeSchema (legacy DB without providers.apiType)', () => {
expect(sqlite.prepare(`SELECT apiCredentials FROM provider_accounts`).get()).toEqual({ apiCredentials: 'u:p' })
})
})
describe('ensureRuntimeSchema (notification_state keys)', () => {
afterEach(() => resetRuntimeMigrate())
it('prefixes plain keys of non-main spaces so main can insert its own', () => {
const sqlite = new Database(':memory:')
resetRuntimeMigrate()
ensureRuntimeSchema(sqlite)
sqlite.exec(`INSERT INTO notification_state (key, spaceId) VALUES ('payment_expiry', 'space-x'), ('low_balance', 'space-main')`)
resetRuntimeMigrate()
ensureRuntimeSchema(sqlite)
const keys = (sqlite.prepare(`SELECT key FROM notification_state ORDER BY key`).all() as { key: string }[]).map((r) => r.key)
expect(keys).toEqual(['low_balance', 'space-x::payment_expiry'])
})
})
+5
View File
@@ -556,5 +556,10 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
rebuildVpsDomainsBindingUnique(sqlite)
ensureMainSpace(sqlite)
backfillSpaceIds(sqlite)
// Non-main spaces' notification_state rows written before key prefixing would block main's inserts.
sqlite.exec(
`UPDATE notification_state SET key = spaceId || '::' || key
WHERE spaceId != '${MAIN_SPACE_ID}' AND instr(key, '::') = 0`,
)
migrated = true
}