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.
This commit is contained in:
Matthias G
2026-08-03 12:53:13 +02:00
parent 81d0489c78
commit 768bbca59c
7 changed files with 80 additions and 17 deletions
+7 -3
View File
@@ -295,7 +295,10 @@
"runNotDeletable": "Dieser Lauf ging über den Webhook, dafür gibt es keine Nachrichtenkennung.", "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.", "runNoToken": "Ohne Bot-Token lässt sich in Slack nichts löschen.",
"runDeleteFailed": "Slack hat das Löschen abgelehnt.", "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": { "errors": {
"nameRequired": "Trag einen Namen ein.", "nameRequired": "Trag einen Namen ein.",
@@ -763,10 +766,11 @@
"messageDelete": "In Slack löschen", "messageDelete": "In Slack löschen",
"messageDeleting": "Wird gelöscht", "messageDeleting": "Wird gelöscht",
"messageDeleted": "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.", "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.", "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": { "filter": {
+7 -3
View File
@@ -295,7 +295,10 @@
"runNotDeletable": "That run went through the webhook, so there is no message id.", "runNotDeletable": "That run went through the webhook, so there is no message id.",
"runNoToken": "Without a bot token nothing can be deleted in Slack.", "runNoToken": "Without a bot token nothing can be deleted in Slack.",
"runDeleteFailed": "Slack refused the deletion.", "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": { "errors": {
"nameRequired": "Enter a name.", "nameRequired": "Enter a name.",
@@ -763,10 +766,11 @@
"messageDelete": "Delete in Slack", "messageDelete": "Delete in Slack",
"messageDeleting": "Deleting", "messageDeleting": "Deleting",
"messageDeleted": "Deleted", "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.", "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.", "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": { "filter": {
+4 -3
View File
@@ -40,6 +40,7 @@ export default async function AdminSettingsPage() {
const format = await getFormatter() const format = await getFormatter()
const config = await readSlackConfig() const config = await readSlackConfig()
const runs = await listReportRuns(historyCount) const runs = await listReportRuns(historyCount)
const canDelete = config.hasBot
return ( return (
<div className="flex flex-col gap-12 pt-12"> <div className="flex flex-col gap-12 pt-12">
@@ -104,9 +105,9 @@ export default async function AdminSettingsPage() {
<td className={`${cellClass} whitespace-nowrap`}> <td className={`${cellClass} whitespace-nowrap`}>
{run.deletedAt {run.deletedAt
? t('messageDeleted') ? t('messageDeleted')
: run.messageTs : run.sent && canDelete
? <MessageDeleteButton runId={run.id} /> ? <MessageDeleteButton runId={run.id} hint={run.messageTs ? undefined : t('messageWebhook')} />
: run.sent ? t('messageWebhook') : '-'} : run.sent ? t('messageNoToken') : '-'}
</td> </td>
</tr> </tr>
))} ))}
+3 -1
View File
@@ -10,9 +10,10 @@ import { emptyAdminFormState } from '~/lib/admin-forms'
export type MessageDeleteButtonProps = { export type MessageDeleteButtonProps = {
runId: string runId: string
hint?: string
} }
export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) { export function MessageDeleteButton({ runId, hint }: MessageDeleteButtonProps) {
const t = useTranslations('admin.settings') const t = useTranslations('admin.settings')
const messages = useTranslations('admin.messages') const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(deleteSlackMessage, emptyAdminFormState) const [state, formAction] = useActionState(deleteSlackMessage, emptyAdminFormState)
@@ -27,6 +28,7 @@ export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) {
<AdminSubmit quiet pendingLabel={t('messageDeleting')} icon={<TbTrash className="size-4" />}> <AdminSubmit quiet pendingLabel={t('messageDeleting')} icon={<TbTrash className="size-4" />}>
{t('messageDelete')} {t('messageDelete')}
</AdminSubmit> </AdminSubmit>
{hint ? <span className="font-mono text-micro text-ink-3">{hint}</span> : null}
{state.status === 'error' && state.message ? ( {state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>{messages(state.message)}</span> <span role="alert" className={errorClass}>{messages(state.message)}</span>
) : null} ) : null}
+23 -6
View File
@@ -8,7 +8,8 @@ import {
} from '~/data/repositories/settings' } from '~/data/repositories/settings'
import { invalid, isUuid, readField, readFlag, type AdminFormState } from './admin-forms' 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 { deleteFromSlack, findMessageTs } from './slack'
import { reportText } from './weekly-report'
import type { Viewer } from './auth-access' import type { Viewer } from './auth-access'
function looksLikeWebhook(value: string): boolean { function looksLikeWebhook(value: string): boolean {
@@ -108,18 +109,34 @@ export async function performDeleteMessage(viewer: Viewer, formData: FormData):
return invalid('runUnknown') return invalid('runUnknown')
} }
if (!run.channel || !run.messageTs) { if (!run.sent) {
return invalid('runNotDeletable') 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 token = values.get('slack.botToken')?.trim()
const channel = run.channel ?? values.get('slack.channel')?.trim()
if (!token) { if (!token) {
return invalid('runNoToken') 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) { if (!result.ok) {
return invalid('runDeleteFailed') return invalid('runDeleteFailed')
@@ -133,7 +150,7 @@ export async function performDeleteMessage(viewer: Viewer, formData: FormData):
action: 'settings.reportDelete', action: 'settings.reportDelete',
entity: 'setting', entity: 'setting',
entityId: run.id, entityId: run.id,
data: { channel: run.channel, messageTs: run.messageTs }, data: { channel, messageTs },
}) })
return { status: 'ok', message: 'messageDeleted' } return { status: 'ok', message: 'messageDeleted' }
+29
View File
@@ -78,3 +78,32 @@ export async function deleteFromSlack(token: string, channel: string, messageTs:
return { ok: true, sent: { channel, messageTs } } return { ok: true, sent: { channel, messageTs } }
} }
export async function findMessageTs(args: {
token: string
channel: string
text: string
around: Date
window?: number
}): Promise<string | undefined> {
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
}
+7 -1
View File
@@ -35,6 +35,12 @@ export type SlackMessage = {
const dateFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) 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 } { export function windowFor(now: Date, days = reportDays): { from: Date, to: Date } {
const to = new Date(now) const to = new Date(now)
const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000) 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 period = `${dateFormat.format(report.from)} bis ${dateFormat.format(report.to)}`
const entries = count(report.total, 'Eintrag', 'Einträge') const entries = count(report.total, 'Eintrag', 'Einträge')
const text = `Logbuch, ${period}: ${entries}` const text = reportText(report)
const blocks: unknown[] = [ const blocks: unknown[] = [
{ type: 'header', text: { type: 'plain_text', text: 'Logbuch' } }, { type: 'header', text: { type: 'plain_text', text: 'Logbuch' } },