From 768bbca59c31be9370aee1cd7cd8c89b0bee0858 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Mon, 3 Aug 2026 12:53:13 +0200 Subject: [PATCH] Delete webhook messages too by looking up their id A message sent through the webhook has no stored id, but it belongs to the same Slack app, so the bot token may remove it. The delete button now looks the message up in the channel by its text and time before deleting. --- messages/de.json | 10 +++++-- messages/en.json | 10 +++++-- src/app/admin/settings/page.tsx | 7 +++-- src/components/admin/MessageDeleteButton.tsx | 4 ++- src/lib/admin-settings.ts | 29 ++++++++++++++++---- src/lib/slack.ts | 29 ++++++++++++++++++++ src/lib/weekly-report.ts | 8 +++++- 7 files changed, 80 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index 0f99089..2a50716 100644 --- a/messages/de.json +++ b/messages/de.json @@ -295,7 +295,10 @@ "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." + "tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp.", + "runNotSent": "Bei diesem Lauf ging keine Nachricht raus.", + "runNoChannel": "Es ist kein Kanal hinterlegt.", + "runNotFound": "Im Kanal war keine passende Nachricht zu finden. Braucht der Bot noch das Recht channels:history?" }, "errors": { "nameRequired": "Trag einen Namen ein.", @@ -763,10 +766,11 @@ "messageDelete": "In Slack löschen", "messageDeleting": "Wird gelöscht", "messageDeleted": "Gelöscht", - "messageWebhook": "Über Webhook, nicht löschbar", + "messageWebhook": "Über Webhook, Kennung wird gesucht", "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." + "pathNone": "Noch kein Weg hinterlegt, es geht nichts raus.", + "messageNoToken": "Ohne Bot-Token nicht löschbar" } }, "filter": { diff --git a/messages/en.json b/messages/en.json index b56c918..7d70bbe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -295,7 +295,10 @@ "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." + "tokenInvalid": "A bot token starts with xoxb or xoxp.", + "runNotSent": "That run sent no message.", + "runNoChannel": "No channel is stored.", + "runNotFound": "No matching message found in the channel. Does the bot still need channels:history?" }, "errors": { "nameRequired": "Enter a name.", @@ -763,10 +766,11 @@ "messageDelete": "Delete in Slack", "messageDeleting": "Deleting", "messageDeleted": "Deleted", - "messageWebhook": "Sent by webhook, cannot be deleted", + "messageWebhook": "Sent by webhook, id will be looked up", "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." + "pathNone": "No route stored yet, nothing goes out.", + "messageNoToken": "Not deletable without a bot token" } }, "filter": { diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 826bbd1..4d088e5 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -40,6 +40,7 @@ export default async function AdminSettingsPage() { const format = await getFormatter() const config = await readSlackConfig() const runs = await listReportRuns(historyCount) + const canDelete = config.hasBot return (
@@ -104,9 +105,9 @@ export default async function AdminSettingsPage() { {run.deletedAt ? t('messageDeleted') - : run.messageTs - ? - : run.sent ? t('messageWebhook') : '-'} + : run.sent && canDelete + ? + : run.sent ? t('messageNoToken') : '-'} ))} diff --git a/src/components/admin/MessageDeleteButton.tsx b/src/components/admin/MessageDeleteButton.tsx index 56d8622..2489792 100644 --- a/src/components/admin/MessageDeleteButton.tsx +++ b/src/components/admin/MessageDeleteButton.tsx @@ -10,9 +10,10 @@ import { emptyAdminFormState } from '~/lib/admin-forms' export type MessageDeleteButtonProps = { runId: string + hint?: string } -export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) { +export function MessageDeleteButton({ runId, hint }: MessageDeleteButtonProps) { const t = useTranslations('admin.settings') const messages = useTranslations('admin.messages') const [state, formAction] = useActionState(deleteSlackMessage, emptyAdminFormState) @@ -27,6 +28,7 @@ export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) { }> {t('messageDelete')} + {hint ? {hint} : null} {state.status === 'error' && state.message ? ( {messages(state.message)} ) : null} diff --git a/src/lib/admin-settings.ts b/src/lib/admin-settings.ts index aa7dbf1..9ae07fb 100644 --- a/src/lib/admin-settings.ts +++ b/src/lib/admin-settings.ts @@ -8,7 +8,8 @@ import { } from '~/data/repositories/settings' import { invalid, isUuid, readField, readFlag, type AdminFormState } from './admin-forms' import { runWeeklyReport } from './report-settings' -import { deleteFromSlack } from './slack' +import { deleteFromSlack, findMessageTs } from './slack' +import { reportText } from './weekly-report' import type { Viewer } from './auth-access' function looksLikeWebhook(value: string): boolean { @@ -108,18 +109,34 @@ export async function performDeleteMessage(viewer: Viewer, formData: FormData): return invalid('runUnknown') } - if (!run.channel || !run.messageTs) { - return invalid('runNotDeletable') + if (!run.sent) { + return invalid('runNotSent') } - const values = await readSettings(['slack.botToken']) + const values = await readSettings(['slack.botToken', 'slack.channel']) const token = values.get('slack.botToken')?.trim() + const channel = run.channel ?? values.get('slack.channel')?.trim() if (!token) { return invalid('runNoToken') } - const result = await deleteFromSlack(token, run.channel, run.messageTs) + if (!channel) { + return invalid('runNoChannel') + } + + const messageTs = run.messageTs ?? await findMessageTs({ + token, + channel, + text: reportText({ from: run.fromAt, to: run.toAt, total: run.total }), + around: run.createdAt, + }) + + if (!messageTs) { + return invalid('runNotFound') + } + + const result = await deleteFromSlack(token, channel, messageTs) if (!result.ok) { return invalid('runDeleteFailed') @@ -133,7 +150,7 @@ export async function performDeleteMessage(viewer: Viewer, formData: FormData): action: 'settings.reportDelete', entity: 'setting', entityId: run.id, - data: { channel: run.channel, messageTs: run.messageTs }, + data: { channel, messageTs }, }) return { status: 'ok', message: 'messageDeleted' } diff --git a/src/lib/slack.ts b/src/lib/slack.ts index 24c4a4a..6669545 100644 --- a/src/lib/slack.ts +++ b/src/lib/slack.ts @@ -78,3 +78,32 @@ export async function deleteFromSlack(token: string, channel: string, messageTs: return { ok: true, sent: { channel, messageTs } } } + +export async function findMessageTs(args: { + token: string + channel: string + text: string + around: Date + window?: number +}): Promise { + const window = args.window ?? 30 * 60 * 1000 + const oldest = (args.around.getTime() - window) / 1000 + const latest = (args.around.getTime() + window) / 1000 + + const { ok, data } = await callApi(args.token, 'conversations.history', { + channel: args.channel, + oldest: String(oldest), + latest: String(latest), + inclusive: true, + limit: 100, + }) + + if (!ok || !Array.isArray(data.messages)) { + return undefined + } + + const found = (data.messages as { text?: string, ts?: string }[]) + .find(message => typeof message.ts === 'string' && message.text === args.text) + + return found?.ts +} diff --git a/src/lib/weekly-report.ts b/src/lib/weekly-report.ts index f08da33..23e53b8 100644 --- a/src/lib/weekly-report.ts +++ b/src/lib/weekly-report.ts @@ -35,6 +35,12 @@ export type SlackMessage = { const dateFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) +export function reportText(args: { from: Date, to: Date, total: number }): string { + const period = `${dateFormat.format(args.from)} bis ${dateFormat.format(args.to)}` + + return `Logbuch, ${period}: ${args.total === 1 ? '1 Eintrag' : `${args.total} Einträge`}` +} + export function windowFor(now: Date, days = reportDays): { from: Date, to: Date } { const to = new Date(now) const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000) @@ -145,7 +151,7 @@ export function buildMessage( const period = `${dateFormat.format(report.from)} bis ${dateFormat.format(report.to)}` const entries = count(report.total, 'Eintrag', 'Einträge') - const text = `Logbuch, ${period}: ${entries}` + const text = reportText(report) const blocks: unknown[] = [ { type: 'header', text: { type: 'plain_text', text: 'Logbuch' } },