Send through a bot token so messages can be deleted again

Incoming webhooks return no message id, so nothing can be removed later.
Settings now take a bot token plus channel, chat.postMessage records channel
and timestamp on the run, and the history carries a delete button per
message. The webhook stays as a fallback and is marked as not deletable.
This commit is contained in:
Matthias G
2026-08-03 12:45:04 +02:00
parent 6c78acdd6d
commit 81d0489c78
16 changed files with 2450 additions and 42 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ Antwort: `{ "published": [...], "meta": { "total": 0 } }`.
### POST /api/v1/weekly-report ### POST /api/v1/weekly-report
Sammelt alles, was in den letzten sieben Tagen veröffentlicht wurde, gruppiert es nach Projekt und schickt es als eine Nachricht nach Slack. Den Webhook nimmt Logbuch aus der Verwaltung unter Einstellungen, sonst aus `SLACK_WEBHOOK_URL`. Verlangt ebenfalls `CRON_SECRET`. Jeder Lauf landet im Verlauf auf derselben Seite. Sammelt alles, was in den letzten sieben Tagen veröffentlicht wurde, gruppiert es nach Projekt und schickt es als eine Nachricht nach Slack. Den Weg nach Slack nimmt Logbuch aus der Verwaltung unter Einstellungen: Bot-Token mit Kanal, sonst Webhook, sonst `SLACK_WEBHOOK_URL`. Nur beim Bot-Token merkt sich Logbuch die Nachrichtenkennung und kann sie später wieder löschen. Verlangt ebenfalls `CRON_SECRET`. Jeder Lauf landet im Verlauf auf derselben Seite.
| Parameter | Wirkung | | Parameter | Wirkung |
|---|---| |---|---|
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "report_run" ADD COLUMN "channel" text;--> statement-breakpoint
ALTER TABLE "report_run" ADD COLUMN "message_ts" text;--> statement-breakpoint
ALTER TABLE "report_run" ADD COLUMN "deleted_at" timestamp with time zone;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -85,6 +85,13 @@
"when": 1785751667390, "when": 1785751667390,
"tag": "0011_concerned_domino", "tag": "0011_concerned_domino",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1785753181183,
"tag": "0012_solid_tarantula",
"breakpoints": true
} }
] ]
} }
+27 -6
View File
@@ -289,7 +289,13 @@
"reportSent": "Der Bericht ist raus.", "reportSent": "Der Bericht ist raus.",
"reportEmpty": "In den letzten sieben Tagen wurde nichts veröffentlicht, deshalb ging keine Nachricht raus.", "reportEmpty": "In den letzten sieben Tagen wurde nichts veröffentlicht, deshalb ging keine Nachricht raus.",
"reportNoWebhook": "Es ist kein Webhook hinterlegt.", "reportNoWebhook": "Es ist kein Webhook hinterlegt.",
"reportRejected": "Slack hat die Nachricht abgelehnt." "reportRejected": "Slack hat die Nachricht abgelehnt.",
"messageDeleted": "Die Nachricht ist in Slack gelöscht.",
"runUnknown": "Diesen Lauf gibt es nicht.",
"runNotDeletable": "Dieser Lauf ging über den Webhook, dafür gibt es keine Nachrichtenkennung.",
"runNoToken": "Ohne Bot-Token lässt sich in Slack nichts löschen.",
"runDeleteFailed": "Slack hat das Löschen abgelehnt.",
"tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp."
}, },
"errors": { "errors": {
"nameRequired": "Trag einen Namen ein.", "nameRequired": "Trag einen Namen ein.",
@@ -726,7 +732,8 @@
"total": "Einträge", "total": "Einträge",
"projects": "Projekte", "projects": "Projekte",
"result": "Ergebnis", "result": "Ergebnis",
"trigger": "Ausgelöst von" "trigger": "Ausgelöst von",
"message": "Nachricht"
}, },
"results": { "results": {
"sent": "Verschickt", "sent": "Verschickt",
@@ -736,16 +743,30 @@
}, },
"fields": { "fields": {
"webhook": "Incoming Webhook", "webhook": "Incoming Webhook",
"includeInternal": "Interne Beiträge mitschicken" "includeInternal": "Interne Beiträge mitschicken",
"botToken": "Bot-Token",
"channel": "Kanal"
}, },
"hints": { "hints": {
"webhook": "Aus Slack unter Incoming Webhooks. Die Adresse hängt fest an einem Kanal.", "webhook": "Aus Slack unter Incoming Webhooks. Die Adresse hängt fest an einem Kanal.",
"includeInternal": "Slack ist intern, deshalb standardmäßig an. Aus heißt nur Kunden- und öffentliche Beiträge.", "includeInternal": "Slack ist intern, deshalb standardmäßig an. Aus heißt nur Kunden- und öffentliche Beiträge.",
"sendNow": "Schickt den Stand der letzten sieben Tage sofort in den Kanal." "sendNow": "Schickt den Stand der letzten sieben Tage sofort in den Kanal.",
"botToken": "Aus der Slack-App unter OAuth & Permissions, beginnt mit xoxb. Mit Bot-Token lassen sich Nachrichten später wieder löschen.",
"channel": "Name oder Kennung des Kanals, etwa logbuch oder C01234567."
}, },
"errors": { "errors": {
"webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus." "webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus.",
} "tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp."
},
"tokenStored": "Hinterlegt, wird nicht mehr angezeigt.",
"replaceToken": "Anderen Token eintragen",
"messageDelete": "In Slack löschen",
"messageDeleting": "Wird gelöscht",
"messageDeleted": "Gelöscht",
"messageWebhook": "Über Webhook, nicht löschbar",
"pathBot": "Versand über den Bot-Token. Nachrichten lassen sich hier wieder löschen.",
"pathWebhook": "Versand über den Webhook. Nachrichten lassen sich danach nicht mehr löschen, dafür braucht es einen Bot-Token.",
"pathNone": "Noch kein Weg hinterlegt, es geht nichts raus."
} }
}, },
"filter": { "filter": {
+27 -6
View File
@@ -289,7 +289,13 @@
"reportSent": "The digest is out.", "reportSent": "The digest is out.",
"reportEmpty": "Nothing was published in the last seven days, so no message went out.", "reportEmpty": "Nothing was published in the last seven days, so no message went out.",
"reportNoWebhook": "No webhook is stored.", "reportNoWebhook": "No webhook is stored.",
"reportRejected": "Slack rejected the message." "reportRejected": "Slack rejected the message.",
"messageDeleted": "The message is deleted in Slack.",
"runUnknown": "That run does not exist.",
"runNotDeletable": "That run went through the webhook, so there is no message id.",
"runNoToken": "Without a bot token nothing can be deleted in Slack.",
"runDeleteFailed": "Slack refused the deletion.",
"tokenInvalid": "A bot token starts with xoxb or xoxp."
}, },
"errors": { "errors": {
"nameRequired": "Enter a name.", "nameRequired": "Enter a name.",
@@ -726,7 +732,8 @@
"total": "Entries", "total": "Entries",
"projects": "Projects", "projects": "Projects",
"result": "Result", "result": "Result",
"trigger": "Triggered by" "trigger": "Triggered by",
"message": "Message"
}, },
"results": { "results": {
"sent": "Sent", "sent": "Sent",
@@ -736,16 +743,30 @@
}, },
"fields": { "fields": {
"webhook": "Incoming webhook", "webhook": "Incoming webhook",
"includeInternal": "Include internal entries" "includeInternal": "Include internal entries",
"botToken": "Bot token",
"channel": "Channel"
}, },
"hints": { "hints": {
"webhook": "From Slack under Incoming Webhooks. The address is bound to one channel.", "webhook": "From Slack under Incoming Webhooks. The address is bound to one channel.",
"includeInternal": "Slack is internal, so this is on by default. Off means customer and public entries only.", "includeInternal": "Slack is internal, so this is on by default. Off means customer and public entries only.",
"sendNow": "Sends the last seven days into the channel right away." "sendNow": "Sends the last seven days into the channel right away.",
"botToken": "From the Slack app under OAuth & Permissions, starts with xoxb. A bot token lets messages be deleted later.",
"channel": "Channel name or id, for example logbuch or C01234567."
}, },
"errors": { "errors": {
"webhookInvalid": "That does not look like a Slack webhook address." "webhookInvalid": "That does not look like a Slack webhook address.",
} "tokenInvalid": "A bot token starts with xoxb or xoxp."
},
"tokenStored": "Stored, no longer shown.",
"replaceToken": "Enter a different token",
"messageDelete": "Delete in Slack",
"messageDeleting": "Deleting",
"messageDeleted": "Deleted",
"messageWebhook": "Sent by webhook, cannot be deleted",
"pathBot": "Sending through the bot token. Messages can be deleted from here.",
"pathWebhook": "Sending through the webhook. Messages cannot be deleted afterwards, that needs a bot token.",
"pathNone": "No route stored yet, nothing goes out."
} }
}, },
"filter": { "filter": {
+12 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import { getFormatter, getTranslations } from 'next-intl/server' import { getFormatter, getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading' import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice' import { AdminNotice } from '~/components/admin/AdminNotice'
import { MessageDeleteButton } from '~/components/admin/MessageDeleteButton'
import { SlackForm } from '~/components/admin/SlackForm' import { SlackForm } from '~/components/admin/SlackForm'
import { cellClass, headCellClass } from '~/components/admin/styles' import { cellClass, headCellClass } from '~/components/admin/styles'
import { SectionLabel } from '~/components/ui/SectionLabel' import { SectionLabel } from '~/components/ui/SectionLabel'
@@ -47,7 +48,9 @@ export default async function AdminSettingsPage() {
<section className="flex flex-col gap-5"> <section className="flex flex-col gap-5">
<SectionLabel>{t('slack')}</SectionLabel> <SectionLabel>{t('slack')}</SectionLabel>
<SlackForm <SlackForm
hasWebhook={config.webhook !== undefined} hasWebhook={config.target?.kind === 'webhook'}
hasBot={config.hasBot}
channel={config.channel}
fromEnv={config.fromEnv} fromEnv={config.fromEnv}
includeInternal={config.includeInternal} includeInternal={config.includeInternal}
/> />
@@ -76,6 +79,7 @@ export default async function AdminSettingsPage() {
<th scope="col" className={headCellClass}>{t('columns.projects')}</th> <th scope="col" className={headCellClass}>{t('columns.projects')}</th>
<th scope="col" className={headCellClass}>{t('columns.result')}</th> <th scope="col" className={headCellClass}>{t('columns.result')}</th>
<th scope="col" className={headCellClass}>{t('columns.trigger')}</th> <th scope="col" className={headCellClass}>{t('columns.trigger')}</th>
<th scope="col" className={headCellClass}>{t('columns.message')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -97,6 +101,13 @@ export default async function AdminSettingsPage() {
</td> </td>
<td className={`${cellClass} whitespace-nowrap`}>{t(`results.${resultKey(run)}`)}</td> <td className={`${cellClass} whitespace-nowrap`}>{t(`results.${resultKey(run)}`)}</td>
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>{run.trigger}</td> <td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>{run.trigger}</td>
<td className={`${cellClass} whitespace-nowrap`}>
{run.deletedAt
? t('messageDeleted')
: run.messageTs
? <MessageDeleteButton runId={run.id} />
: run.sent ? t('messageWebhook') : '-'}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -0,0 +1,35 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbTrash } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass } from './styles'
import { deleteSlackMessage } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type MessageDeleteButtonProps = {
runId: string
}
export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) {
const t = useTranslations('admin.settings')
const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(deleteSlackMessage, emptyAdminFormState)
if (state.status === 'ok') {
return <span className="font-mono text-micro text-ink-3">{t('messageDeleted')}</span>
}
return (
<form action={formAction} className="flex flex-col gap-1">
<input type="hidden" name="runId" value={runId} />
<AdminSubmit quiet pendingLabel={t('messageDeleting')} icon={<TbTrash className="size-4" />}>
{t('messageDelete')}
</AdminSubmit>
{state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>{messages(state.message)}</span>
) : null}
</form>
)
}
+50 -1
View File
@@ -12,21 +12,70 @@ import { emptyAdminFormState } from '~/lib/admin-forms'
export type SlackFormProps = { export type SlackFormProps = {
hasWebhook: boolean hasWebhook: boolean
hasBot: boolean
channel: string
fromEnv: boolean fromEnv: boolean
includeInternal: boolean includeInternal: boolean
} }
export function SlackForm({ hasWebhook, fromEnv, includeInternal }: SlackFormProps) { export function SlackForm({ hasWebhook, hasBot, channel, fromEnv, includeInternal }: SlackFormProps) {
const t = useTranslations('admin.settings') const t = useTranslations('admin.settings')
const [state, formAction] = useActionState(saveSlackSettings, emptyAdminFormState) const [state, formAction] = useActionState(saveSlackSettings, emptyAdminFormState)
const [report, reportAction] = useActionState(sendWeeklyReportNow, emptyAdminFormState) const [report, reportAction] = useActionState(sendWeeklyReportNow, emptyAdminFormState)
const [replace, setReplace] = useState(!hasWebhook) const [replace, setReplace] = useState(!hasWebhook)
const [replaceToken, setReplaceToken] = useState(!hasBot)
const path = hasBot ? 'pathBot' : hasWebhook ? 'pathWebhook' : 'pathNone'
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<form action={formAction} className="flex max-w-3xl flex-col gap-6"> <form action={formAction} className="flex max-w-3xl flex-col gap-6">
<AdminFormMessage state={state} /> <AdminFormMessage state={state} />
<p className="m-0 text-pretty text-ink-2">{t(path)}</p>
{hasBot && !replaceToken ? (
<div className="flex flex-col gap-2">
<span className={labelClass}>{t('fields.botToken')}</span>
<span className="font-mono text-small text-ink-2">{t('tokenStored')}</span>
<button
type="button"
onClick={() => setReplaceToken(true)}
className="cursor-pointer self-start bg-transparent p-0 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-signal"
>
{t('replaceToken')}
</button>
<input type="hidden" name="keepToken" value="on" />
</div>
) : (
<AdminField
id="botToken"
label={t('fields.botToken')}
hint={t('hints.botToken')}
error={state.fields?.botToken ? t(`errors.${state.fields.botToken}`) : undefined}
>
<input
id="botToken"
name="botToken"
type="password"
autoComplete="off"
placeholder="xoxb-..."
className={inputClass}
/>
</AdminField>
)}
<AdminField id="channel" label={t('fields.channel')} hint={t('hints.channel')}>
<input
id="channel"
name="channel"
type="text"
defaultValue={channel}
autoComplete="off"
placeholder="logbuch"
className={inputClass}
/>
</AdminField>
{hasWebhook && !replace ? ( {hasWebhook && !replace ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className={labelClass}>{t('fields.webhook')}</span> <span className={labelClass}>{t('fields.webhook')}</span>
+13 -1
View File
@@ -2,7 +2,7 @@ import { desc, eq, inArray } from 'drizzle-orm'
import { db } from '../db' import { db } from '../db'
import { appSettings, reportRuns, type ReportRun } from '../schema' import { appSettings, reportRuns, type ReportRun } from '../schema'
export type SettingKey = 'slack.webhook' | 'slack.includeInternal' export type SettingKey = 'slack.webhook' | 'slack.botToken' | 'slack.channel' | 'slack.includeInternal'
export async function readSettings(keys: SettingKey[]): Promise<Map<SettingKey, string>> { export async function readSettings(keys: SettingKey[]): Promise<Map<SettingKey, string>> {
const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys)) const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys))
@@ -29,6 +29,8 @@ export type ReportRunValues = {
sent: boolean sent: boolean
trigger: string trigger: string
detail?: string | null detail?: string | null
channel?: string | null
messageTs?: string | null
} }
export async function recordReportRun(values: ReportRunValues): Promise<ReportRun> { export async function recordReportRun(values: ReportRunValues): Promise<ReportRun> {
@@ -37,6 +39,16 @@ export async function recordReportRun(values: ReportRunValues): Promise<ReportRu
return rows[0]! return rows[0]!
} }
export async function findReportRun(id: string): Promise<ReportRun | undefined> {
const rows = await db.select().from(reportRuns).where(eq(reportRuns.id, id)).limit(1)
return rows[0]
}
export async function markReportRunDeleted(id: string): Promise<void> {
await db.update(reportRuns).set({ deletedAt: new Date() }).where(eq(reportRuns.id, id))
}
export async function listReportRuns(limit: number): Promise<ReportRun[]> { export async function listReportRuns(limit: number): Promise<ReportRun[]> {
return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit) return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit)
} }
+3
View File
@@ -16,6 +16,9 @@ export const reportRuns = pgTable('report_run', {
sent: boolean('sent').notNull().default(false), sent: boolean('sent').notNull().default(false),
trigger: text('trigger').notNull(), trigger: text('trigger').notNull(),
detail: text('detail'), detail: text('detail'),
channel: text('channel'),
messageTs: text('message_ts'),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}) })
export type AppSetting = typeof appSettings.$inferSelect export type AppSetting = typeof appSettings.$inferSelect
+10 -1
View File
@@ -6,7 +6,7 @@ import { performSaveBrand } from './admin-brands'
import { performCreateClient, performRevokeClient } from './admin-clients' import { performCreateClient, performRevokeClient } from './admin-clients'
import { performDeletePostType, performSavePostType } from './admin-post-types' import { performDeletePostType, performSavePostType } from './admin-post-types'
import { performSaveProject } from './admin-projects' import { performSaveProject } from './admin-projects'
import { performSaveSlack, performSendReport } from './admin-settings' import { performDeleteMessage, performSaveSlack, performSendReport } from './admin-settings'
import { performGrantRole, performRevokeRole, performSetUserAdmin } from './admin-users' import { performGrantRole, performRevokeRole, performSetUserAdmin } from './admin-users'
import type { AdminFormState } from './admin-forms' import type { AdminFormState } from './admin-forms'
import { import {
@@ -159,3 +159,12 @@ export async function sendWeeklyReportNow(_state: AdminFormState): Promise<Admin
return result return result
} }
export async function deleteSlackMessage(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
const viewer = await requireAdmin()
const result = await performDeleteMessage(viewer, formData)
revalidatePath(adminSettingsPath)
return result
}
+84 -3
View File
@@ -1,22 +1,40 @@
import { recordAudit } from '~/data/repositories/audit' import { recordAudit } from '~/data/repositories/audit'
import { clearSetting, writeSetting } from '~/data/repositories/settings' import {
import { invalid, readField, readFlag, type AdminFormState } from './admin-forms' clearSetting,
findReportRun,
markReportRunDeleted,
readSettings,
writeSetting,
} from '~/data/repositories/settings'
import { invalid, isUuid, readField, readFlag, type AdminFormState } from './admin-forms'
import { runWeeklyReport } from './report-settings' import { runWeeklyReport } from './report-settings'
import { deleteFromSlack } from './slack'
import type { Viewer } from './auth-access' import type { Viewer } from './auth-access'
function looksLikeWebhook(value: string): boolean { function looksLikeWebhook(value: string): boolean {
return value === '' || /^https:\/\/hooks\.slack\.com\/services\/[\w/+-]+$/u.test(value) return value === '' || /^https:\/\/hooks\.slack\.com\/services\/[\w/+-]+$/u.test(value)
} }
function looksLikeToken(value: string): boolean {
return value === '' || /^xox[bp]-[\w-]+$/u.test(value)
}
export async function performSaveSlack(viewer: Viewer, formData: FormData): Promise<AdminFormState> { export async function performSaveSlack(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
const webhook = readField(formData, 'webhook') const webhook = readField(formData, 'webhook')
const token = readField(formData, 'botToken')
const channel = readField(formData, 'channel')
const keep = readFlag(formData, 'keep') const keep = readFlag(formData, 'keep')
const keepToken = readFlag(formData, 'keepToken')
const includeInternal = readFlag(formData, 'includeInternal') const includeInternal = readFlag(formData, 'includeInternal')
if (!keep && !looksLikeWebhook(webhook)) { if (!keep && !looksLikeWebhook(webhook)) {
return invalid('webhookInvalid', undefined, { webhook: 'webhookInvalid' }) return invalid('webhookInvalid', undefined, { webhook: 'webhookInvalid' })
} }
if (!keepToken && !looksLikeToken(token)) {
return invalid('tokenInvalid', undefined, { botToken: 'tokenInvalid' })
}
if (!keep) { if (!keep) {
if (webhook === '') { if (webhook === '') {
await clearSetting('slack.webhook') await clearSetting('slack.webhook')
@@ -25,6 +43,20 @@ export async function performSaveSlack(viewer: Viewer, formData: FormData): Prom
} }
} }
if (!keepToken) {
if (token === '') {
await clearSetting('slack.botToken')
} else {
await writeSetting('slack.botToken', token)
}
}
if (channel === '') {
await clearSetting('slack.channel')
} else {
await writeSetting('slack.channel', channel)
}
await writeSetting('slack.includeInternal', includeInternal ? 'true' : 'false') await writeSetting('slack.includeInternal', includeInternal ? 'true' : 'false')
await recordAudit({ await recordAudit({
@@ -33,7 +65,12 @@ export async function performSaveSlack(viewer: Viewer, formData: FormData): Prom
action: 'settings.slack', action: 'settings.slack',
entity: 'setting', entity: 'setting',
entityId: 'slack', entityId: 'slack',
data: { webhookSet: keep ? 'unchanged' : webhook !== '', includeInternal }, data: {
webhookSet: keep ? 'unchanged' : webhook !== '',
botTokenSet: keepToken ? 'unchanged' : token !== '',
channel,
includeInternal,
},
}) })
return { status: 'ok', message: 'slackSaved' } return { status: 'ok', message: 'slackSaved' }
@@ -57,3 +94,47 @@ export async function performSendReport(viewer: Viewer, origin: string): Promise
return invalid(outcome.reason === 'nothing_published' ? 'reportEmpty' : outcome.reason === 'not_configured' ? 'reportNoWebhook' : 'reportRejected') return invalid(outcome.reason === 'nothing_published' ? 'reportEmpty' : outcome.reason === 'not_configured' ? 'reportNoWebhook' : 'reportRejected')
} }
export async function performDeleteMessage(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
const id = readField(formData, 'runId')
if (!isUuid(id)) {
return invalid('runUnknown')
}
const run = await findReportRun(id)
if (!run) {
return invalid('runUnknown')
}
if (!run.channel || !run.messageTs) {
return invalid('runNotDeletable')
}
const values = await readSettings(['slack.botToken'])
const token = values.get('slack.botToken')?.trim()
if (!token) {
return invalid('runNoToken')
}
const result = await deleteFromSlack(token, run.channel, run.messageTs)
if (!result.ok) {
return invalid('runDeleteFailed')
}
await markReportRunDeleted(run.id)
await recordAudit({
actorId: viewer.id,
actorLabel: viewer.email,
action: 'settings.reportDelete',
entity: 'setting',
entityId: run.id,
data: { channel: run.channel, messageTs: run.messageTs },
})
return { status: 'ok', message: 'messageDeleted' }
}
+34 -15
View File
@@ -1,12 +1,14 @@
import { readSettings, recordReportRun } from '~/data/repositories/settings' import { readSettings, recordReportRun } from '~/data/repositories/settings'
import { sendToSlack } from './slack' import { sendToSlack, slackWebhook, type SlackTarget } from './slack'
import { buildWeeklyReport, reportDays } from './weekly-report' import { buildWeeklyReport, reportDays } from './weekly-report'
import type { ReportRun } from '~/data/schema' import type { ReportRun } from '~/data/schema'
import type { ViewerScope } from '~/domain/types' import type { ViewerScope } from '~/domain/types'
export type SlackConfig = { export type SlackConfig = {
webhook: string | undefined target: SlackTarget | undefined
fromEnv: boolean fromEnv: boolean
hasBot: boolean
channel: string
includeInternal: boolean includeInternal: boolean
} }
@@ -17,13 +19,24 @@ export type ReportOutcome = {
} }
export async function readSlackConfig(): Promise<SlackConfig> { export async function readSlackConfig(): Promise<SlackConfig> {
const values = await readSettings(['slack.webhook', 'slack.includeInternal']) const values = await readSettings(['slack.webhook', 'slack.botToken', 'slack.channel', 'slack.includeInternal'])
const token = values.get('slack.botToken')?.trim()
const channel = values.get('slack.channel')?.trim() ?? ''
const stored = values.get('slack.webhook')?.trim() const stored = values.get('slack.webhook')?.trim()
const env = process.env.SLACK_WEBHOOK_URL?.trim() const env = slackWebhook()
const webhook = stored || env
const target: SlackTarget | undefined = token && channel !== ''
? { kind: 'bot', token, channel }
: webhook
? { kind: 'webhook', url: webhook }
: undefined
return { return {
webhook: stored || env || undefined, target,
fromEnv: !stored && Boolean(env), fromEnv: target?.kind === 'webhook' && !stored,
hasBot: Boolean(token),
channel,
includeInternal: values.get('slack.includeInternal') !== 'false', includeInternal: values.get('slack.includeInternal') !== 'false',
} }
} }
@@ -65,24 +78,30 @@ export async function runWeeklyReport(args: {
} }
} }
if (!config.webhook) { if (!config.target) {
return { return {
run: await recordReportRun({ ...base, sent: false, detail: 'not_configured' }), run: await recordReportRun({ ...base, sent: false, detail: 'not_configured' }),
reason: 'not_configured', reason: 'not_configured',
} }
} }
const result = await sendToSlack(report.message, config.webhook) const result = await sendToSlack(report.message, config.target)
if (!result.ok) { if (!result.ok) {
const detail = result.detail ?? result.reason return {
run: await recordReportRun({ ...base, sent: false, detail: result.detail }),
reason: 'rejected',
detail: result.detail,
}
}
return { return {
run: await recordReportRun({ ...base, sent: false, detail }), run: await recordReportRun({
reason: 'rejected', ...base,
detail, sent: true,
channel: result.sent.channel,
messageTs: result.sent.messageTs,
}),
reason: 'sent',
} }
}
return { run: await recordReportRun({ ...base, sent: true }), reason: 'sent' }
} }
+59 -8
View File
@@ -1,8 +1,17 @@
import type { SlackMessage } from './weekly-report' import type { SlackMessage } from './weekly-report'
export type SlackTarget =
| { kind: 'bot', token: string, channel: string }
| { kind: 'webhook', url: string }
export type SlackSent = {
channel: string | null
messageTs: string | null
}
export type SlackResult = export type SlackResult =
| { ok: true } | { ok: true, sent: SlackSent }
| { ok: false, reason: 'not_configured' | 'rejected', detail?: string } | { ok: false, detail: string }
export function slackWebhook(): string | undefined { export function slackWebhook(): string | undefined {
const url = process.env.SLACK_WEBHOOK_URL?.trim() const url = process.env.SLACK_WEBHOOK_URL?.trim()
@@ -10,20 +19,62 @@ export function slackWebhook(): string | undefined {
return url === '' ? undefined : url return url === '' ? undefined : url
} }
export async function sendToSlack(message: SlackMessage, webhook = slackWebhook()): Promise<SlackResult> { async function callApi(token: string, method: string, body: unknown): Promise<{ ok: boolean, data: Record<string, unknown> }> {
if (!webhook) { const response = await fetch(`https://slack.com/api/${method}`, {
return { ok: false, reason: 'not_configured' } method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8',
},
body: JSON.stringify(body),
})
const data = await response.json().catch(() => ({})) as Record<string, unknown>
return { ok: response.ok && data.ok === true, data }
}
export async function sendToSlack(message: SlackMessage, target: SlackTarget): Promise<SlackResult> {
if (target.kind === 'bot') {
const { ok, data } = await callApi(target.token, 'chat.postMessage', {
channel: target.channel,
text: message.text,
blocks: message.blocks,
unfurl_links: false,
})
if (!ok) {
return { ok: false, detail: String(data.error ?? 'unknown_error') }
} }
const response = await fetch(webhook, { return {
ok: true,
sent: {
channel: typeof data.channel === 'string' ? data.channel : target.channel,
messageTs: typeof data.ts === 'string' ? data.ts : null,
},
}
}
const response = await fetch(target.url, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(message), body: JSON.stringify(message),
}) })
if (!response.ok) { if (!response.ok) {
return { ok: false, reason: 'rejected', detail: `${response.status} ${await response.text()}` } return { ok: false, detail: `${response.status} ${await response.text()}` }
} }
return { ok: true } return { ok: true, sent: { channel: null, messageTs: null } }
}
export async function deleteFromSlack(token: string, channel: string, messageTs: string): Promise<SlackResult> {
const { ok, data } = await callApi(token, 'chat.delete', { channel, ts: messageTs })
if (!ok) {
return { ok: false, detail: String(data.error ?? 'unknown_error') }
}
return { ok: true, sent: { channel, messageTs } }
} }
+67 -1
View File
@@ -3,6 +3,8 @@ import { db } from '~/data/db'
import { appSettings } from '~/data/schema' import { appSettings } from '~/data/schema'
import { listReportRuns, writeSetting } from '~/data/repositories/settings' import { listReportRuns, writeSetting } from '~/data/repositories/settings'
import { readSlackConfig, reportScope, runWeeklyReport } from '~/lib/report-settings' import { readSlackConfig, reportScope, runWeeklyReport } from '~/lib/report-settings'
import { makeBrand, makeProject } from '../support/admin'
import { makePost } from '../support/entries'
const origin = 'https://logbuch.nyo.de' const origin = 'https://logbuch.nyo.de'
@@ -19,7 +21,8 @@ describe('readSlackConfig', () => {
const config = await readSlackConfig() const config = await readSlackConfig()
expect(config.webhook).toContain('verwaltung') expect(config.target).toMatchObject({ kind: 'webhook' })
expect(config.target && 'url' in config.target ? config.target.url : '').toContain('verwaltung')
expect(config.fromEnv).toBe(false) expect(config.fromEnv).toBe(false)
}) })
@@ -55,3 +58,66 @@ describe('runWeeklyReport', () => {
expect(runs[0]).toMatchObject({ sent: false, total: 0, trigger: 'test', detail: 'nothing_published' }) expect(runs[0]).toMatchObject({ sent: false, total: 0, trigger: 'test', detail: 'nothing_published' })
}) })
}) })
describe('Bot-Token', () => {
it('nimmt Bot-Token und Kanal vor dem Webhook', async () => {
await writeSetting('slack.webhook', 'https://hooks.slack.com/services/a/b/c')
await writeSetting('slack.botToken', 'xoxb-1234')
await writeSetting('slack.channel', 'logbuch')
const config = await readSlackConfig()
expect(config.target).toEqual({ kind: 'bot', token: 'xoxb-1234', channel: 'logbuch' })
expect(config.hasBot).toBe(true)
})
it('braucht zum Bot-Token einen Kanal', async () => {
await writeSetting('slack.botToken', 'xoxb-1234')
const config = await readSlackConfig()
expect(config.target).toBeUndefined()
expect(config.hasBot).toBe(true)
})
it('merkt sich Kanal und Nachrichtenkennung eines Versands', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const post = await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() })
expect(post.id).toBeDefined()
await writeSetting('slack.botToken', 'xoxb-1234')
await writeSetting('slack.channel', 'logbuch')
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify({ ok: true, channel: 'C999', ts: '1785600000.000100' }),
{ headers: { 'content-type': 'application/json' } },
)))
const outcome = await runWeeklyReport({ origin, trigger: 'test' })
expect(outcome.reason).toBe('sent')
expect(outcome.run).toMatchObject({ sent: true, channel: 'C999', messageTs: '1785600000.000100' })
})
it('vermerkt eine Ablehnung mit dem Grund von Slack', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() })
await writeSetting('slack.botToken', 'xoxb-1234')
await writeSetting('slack.channel', 'logbuch')
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify({ ok: false, error: 'channel_not_found' }),
{ headers: { 'content-type': 'application/json' } },
)))
const outcome = await runWeeklyReport({ origin, trigger: 'test' })
expect(outcome.reason).toBe('rejected')
expect(outcome.run).toMatchObject({ sent: false, detail: 'channel_not_found' })
})
})