Skip to content
Open
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
54 changes: 54 additions & 0 deletions .grok/skills/delete-audit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: delete-audit
description: >
Query Slack automator delete/ban audit records (who triggered a wipe, which
reaction, which messages). Use when the user asks what was deleted, who was
banned, delete-audit history, or runs /delete-audit.
---

# Delete-audit query

The automator records every delete (`DELETE_MESSAGE`, `BAN_USER`,
`REPOST_TO_THREAD_AND_DELETE`, `REMOVE_BROADCAST`, `CROSS_CHANNEL_SPAM`) with
the trigger, target user, and message text.

Schema, storage, and cost: `docs/delete-audit.md` in
`/home/alexey/git/au-tomator-lambda`. Do not restate it.

## Credentials

Default AWS Gate vends **sandbox** `817685572750` only. The table and bucket
are in **main** `387546586013`. Sandbox and `cmp-alert-investigator` cannot
`dynamodb:Query` this table.

Need main-account operator credentials (Alexey applying `main/automator`, or a
role that can read the table and bucket). If Gate is closed or the caller is
sandbox-only, say so and stop; do not guess records.

## Query

From `/home/alexey/git/au-tomator-lambda/automator`:

```bash
uv run python scripts/query_delete_audit.py --user U123
uv run python scripts/query_delete_audit.py --date YYYY-MM-DD
uv run python scripts/query_delete_audit.py --user U123 --s3
```

`--user` is the **target** Slack id (the person whose messages were removed),
not the admin who reacted. `--s3` is the forever copy after the 90-day Dynamo
TTL.

If the script is missing, use:

- Dynamo `automator-delete-audit` `eu-west-1`
- `pk = USER#<slack_user_id>`
- GSI `gsi1` `gsi1pk = DATE#YYYY-MM-DD`
- S3 `dtc-automator-delete-audit-387546586013` prefixes `by-user/<id>/` and
`by-date/YYYY/MM/DD/`

## How to report

Show `recorded_at`, `action_type`, `reaction`, `triggered_by`,
`target_user_id`, counts, and message text. For a ban/spam wipe, list each
deleted message (channel, ts, kind, text). Do not dump emails unless asked.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Its only job is to look at the incoming event and quickly decide where it should
|-------|-----------|
| **Reaction added by admin** | Automator |
| **App mention** | Automator / FAQ Assistant |
| **Message event** | Disabled for now |
| **Message event** (user posts, not bots/edits) | Automator spam tracker |
| **Button click** (e.g. from an alert) | Acknowledged only |

The router and automator are separate on purpose: Slack expects a response within about 3 seconds. The router responds immediately and invokes the automator asynchronously, so longer work doesn’t time out.
Expand All @@ -65,6 +65,13 @@ Examples:

The automator can also send a relevant reply to the author of the message you reacted to, so they get clear feedback.

If someone posts the **same or very similar message in multiple channels**, the
automator folds those copies into a DynamoDB aggregate, waits until they go
quiet (~3 minutes, scanned every 5 minutes), deletes them, and DMs Alexey the
same deactivate workflow as `:ban-user:` (summary, then the user id as a
separate message). Config: `spam_tracker` in `automator/src/config.yaml`.
Slack must send `message` events to the router (`message.channels`).

---

## Deployment
Expand Down Expand Up @@ -146,6 +153,11 @@ Each entry defines an emoji (reaction name) and what the automator should do whe
| `FAQ_ASSISTANT` | Calls the Cloudflare FAQ assistant, then posts the reply in the thread. |
| `REMOVE_BROADCAST` | Removes a “also sent to channel” thread reply from the channel (keeps it in the thread). |
| `REPOST_TO_THREAD_AND_DELETE` | Reposts the message to the thread with a custom message, then deletes it from the channel. |
| `BAN_USER` | Deletes the user's recent messages (and thread replies), DMs innocent participants, and DMs the moderator a cleanup summary. |

Every delete (`DELETE_MESSAGE`, `BAN_USER`, `REPOST_TO_THREAD_AND_DELETE`,
`REMOVE_BROADCAST`) is recorded with the triggering reaction, the admin who
reacted, and the deleted message text. See [docs/delete-audit.md](docs/delete-audit.md).

#### Multiple handlers

Expand Down Expand Up @@ -180,6 +192,7 @@ If a placeholder is channel-specific and the channel isn’t in the map, the `de
| `shameless-rules` | DELETE_MESSAGE | Enforce shameless-promo rules; DM author. |
| `jobs-rules` | DELETE_MESSAGE | Enforce job-posting rules; DM author. |
| `ask-ai` | ASK_AI | Generate and post an AI reply in the thread. |
| `ban-user` | BAN_USER | Wipe the author's recent messages and DM the moderator a cleanup summary. |

To change behavior, edit `automator/src/config.yaml` and keep the same structure so the application stays compatible.

Expand Down
9 changes: 9 additions & 0 deletions automator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ mentions. The router invokes it asynchronously:
See the top-level [README](../README.md) for the configuration reference
(`src/config.yaml`) and the FAQ assistant setup.

Delete / ban-user actions are recorded (trigger, target user, message text)
in DynamoDB + S3. See [docs/delete-audit.md](../docs/delete-audit.md). Query
them with `/delete-audit`.

Cross-channel copy-paste spam is pre-aggregated in DynamoDB (`spam.py`). A
5-minute EventBridge scan deletes quiet sessions and DMs Alexey the same
deactivate flow as `:ban-user:`. Requires `message.channels` on the Slack app
and the Dynamo/EventBridge resources in `aws-infra/main/automator`.

## Layout

```
Expand Down
112 changes: 112 additions & 0 deletions automator/scripts/query_delete_audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Query recorded Slack delete actions.

Examples:
python scripts/query_delete_audit.py --user U123
python scripts/query_delete_audit.py --date 2026-04-08
python scripts/query_delete_audit.py --user U123 --s3
"""

from __future__ import annotations

import argparse
import json
import os
import sys

import boto3


TABLE = os.getenv('AUDIT_TABLE', 'automator-delete-audit')
BUCKET = os.getenv('AUDIT_BUCKET', 'dtc-automator-delete-audit-387546586013')
REGION = os.getenv('AWS_REGION', 'eu-west-1')


def _print(records):
print(json.dumps(records, indent=2, default=str))
print(f'{len(records)} record(s)', file=sys.stderr)


def query_user(user_id):
dynamodb = boto3.resource('dynamodb', region_name=REGION)
table = dynamodb.Table(TABLE)
items = []
kwargs = {
'KeyConditionExpression': boto3.dynamodb.conditions.Key('pk').eq(
f'USER#{user_id}'
)
}
while True:
resp = table.query(**kwargs)
items.extend(resp.get('Items', []))
if 'LastEvaluatedKey' not in resp:
break
kwargs['ExclusiveStartKey'] = resp['LastEvaluatedKey']
return items


def query_date(day):
dynamodb = boto3.resource('dynamodb', region_name=REGION)
table = dynamodb.Table(TABLE)
items = []
kwargs = {
'IndexName': 'gsi1',
'KeyConditionExpression': boto3.dynamodb.conditions.Key('gsi1pk').eq(
f'DATE#{day}'
),
}
while True:
resp = table.query(**kwargs)
items.extend(resp.get('Items', []))
if 'LastEvaluatedKey' not in resp:
break
kwargs['ExclusiveStartKey'] = resp['LastEvaluatedKey']
return items


def list_s3(user_id=None, day=None):
s3 = boto3.client('s3', region_name=REGION)
if user_id:
prefix = f'by-user/{user_id}/'
if day:
year, month, rest = day.split('-', 2)
prefix = f'by-user/{user_id}/{year}/{month}/{rest}/'
elif day:
year, month, rest = day.split('-', 2)
prefix = f'by-date/{year}/{month}/{rest}/'
else:
raise SystemExit('--s3 requires --user and/or --date')

records = []
paginator = s3.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=BUCKET, Prefix=prefix):
for obj in page.get('Contents') or []:
body = s3.get_object(Bucket=BUCKET, Key=obj['Key'])['Body'].read()
records.append(json.loads(body))
return records


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--user', help='Slack user id the action targeted')
parser.add_argument('--date', help='UTC calendar day YYYY-MM-DD')
parser.add_argument(
'--s3', action='store_true',
help='Read the forever S3 copy instead of DynamoDB',
)
args = parser.parse_args()

if args.s3:
_print(list_s3(user_id=args.user, day=args.date))
return
if args.user:
_print(query_user(args.user))
return
if args.date:
_print(query_date(args.date))
return
parser.error('pass --user, --date, or --s3 with one of those')


if __name__ == '__main__':
main()
Loading
Loading