Skill: marcia-assistant/webhook-promo-scheduler (version 0.1.1)

--- name: webhook-promo-scheduler description: Schedule and send promo/alert messages to a Discord webhook URL with an anti-spam ledger. --- ## webhook-promo-scheduler Schedule and send promo/alert messages to a Discord webhook URL with an anti-spam ledger. ### What it does - Posts to a Discord webhook (JSON payload with `content`) - Maintains a JSONL ledger to enforce **max 1 successful post per day per channel** - Provides a rotation mode that cycles through a message list - Supports `--dry-run` so you can validate cadence/ledger without sending anything ### Positioning (best angle) - Ship updates without becoming a spammer (or leaking a webhook): strict cadence + audit trail. ### Files - `scripts/post_webhook.py` : Discord webhook POST helper (stdlib only) - `scripts/ledger.py` : JSONL ledger helpers (stdlib only) - `scripts/promo_scheduler.py` : CLI tool (stdlib only) ### Ledger - Default: `~/.openclaw/webhook-promo-ledger.jsonl` - Override: `--ledger-path /path/to/ledger.jsonl` - JSONL fields (per line): `date`, `channel`, `status`, `hash` ### Safety - The CLI **refuses to print** the webhook URL. - Logs redact any webhook URL if it would appear. - Recommended: keep the webhook in a private channel, rotate it if leaked, and use `--dry-run` before enabling live sends. ### FAQ (security) **Q: This exposes a Discord webhook — is that dangerous?** A: Treat it like a password. This tool won’t print it, supports secret injection, and has `--dry-run` + a ledger so you can validate behavior before turning on live posting. If you want zero direct exposure, put a relay (Cloudflare Worker / Supabase Edge Function) in front and keep the real webhook private. ### Usage One-shot post: ```bash python3 {baseDir}/scripts/promo_scheduler.py post \ --webhook-url <URL> \ --channel openclaw-discord \ --message "Hello from OpenClaw!" ``` Draft rotation: ```bash python3 {baseDir}/scripts/promo_scheduler.py rotate \ --webhook-url <URL> \ --channel openclaw-discord \ --messages-file messages.txt ``` `messages.txt` format: - One message per non-empty line - Lines starting with `#` are ignored

path: scripts/ledger.py
content: #!/usr/bin/env python3
"""Simple JSONL ledger to enforce max 1 post/day/channel.

Each JSON line contains:
  - date: YYYY-MM-DD (local time)
  - channel: arbitrary string
  - status: "reserved" | "posted" | "error"
  - hash: sha256 hex of message content

Stdlib only.
"""

from __future__ import annotations

import datetime as _dt
import json
import os
from dataclasses import dataclass
from typing import Iterable, List, Optional

try:
    import fcntl  # type: ignore
except Exception:
    fcntl = None  # type: ignore

DEFAULT_LEDGER_PATH = os.path.expanduser("~/.openclaw/webhook-promo-ledger.jsonl")


@dataclass(frozen=True)
class LedgerEntry:
    date: str
    channel: str
    status: str
    hash: str


def today_local_yyyy_mm_dd() -> str:
    return _dt.date.today().isoformat()


def ensure_parent_dir(path: str) -> None:
    parent = os.path.dirname(os.path.abspath(path))
    if parent and not os.path.isdir(parent):
        os.makedirs(parent, exist_ok=True)


class Ledger:
    def __init__(self, path: str = DEFAULT_LEDGER_PATH):
        self.path = os.path.expanduser(path)

    def _open_locked(self, mode: str):
        ensure_parent_dir(self.path)
        f = open(self.path, mode, encoding="utf-8")
        if fcntl is not None:
            try:
                fcntl.flock(f.fileno(), fcntl.LOCK_EX)
            except Exception:
                pass
        return f

    def iter_entries(self) -> Iterable[LedgerEntry]:
        if not os.path.exists(self.path):
            return []

        entries: List[LedgerEntry] = []
        with open(self.path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except Exception:
                    continue
                if not isinstance(obj, dict):
                    continue
                date = obj.get("date")
                channel = obj.get("channel")
                status = obj.get("status")
                h = obj.get("hash")
                if (
                    isinstance(date, str)
                    and isinstance(channel, str)
                    and isinstance(status, str)
                    and isinstance(h, str)
                ):
                    entries.append(LedgerEntry(date=date, channel=channel, status=status, hash=h))
        return entries

    def append(self, entry: LedgerEntry) -> None:
        payload = {
            "date": entry.date,
            "channel": entry.channel,
            "status": entry.status,
            "hash": entry.hash,
        }
        line = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
        with self._open_locked("a") as f:
            f.write(line + "\n")
            f.flush()
            os.fsync(f.fileno())

    def already_posted_today(self, channel: str, today: Optional[str] = None) -> bool:
        today_s = today or today_local_yyyy_mm_dd()
        with self._open_locked("a+") as f:
            f.seek(0)
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except Exception:
                    continue
                if not isinstance(obj, dict):
                    continue
                if obj.get("date") != today_s:
                    continue
                if obj.get("channel") != channel:
                    continue
                if obj.get("status") == "posted":
                    return True
        return False

    def last_posted_hash(self, channel: str) -> Optional[str]:
        last: Optional[str] = None
        with self._open_locked("a+") as f:
            f.seek(0)
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except Exception:
                    continue
                if not isinstance(obj, dict):
                    continue
                if obj.get("channel") != channel:
                    continue
                if obj.get("status") != "posted":
                    continue
                h = obj.get("hash")
                if isinstance(h, str):
                    last = h
        return last


path: scripts/post_webhook.py
content: #!/usr/bin/env python3
"""Discord webhook posting helper (stdlib only).

Safety: this module never prints the webhook URL; callers should also redact it.
"""

from __future__ import annotations

import json
import urllib.error
import urllib.request
from typing import Optional, Tuple


def redact_webhook_url(text: str) -> str:
    if not text:
        return text
    lowered = text.lower()
    if "discord.com/api/webhooks" in lowered or "discordapp.com/api/webhooks" in lowered:
        return "<redacted>"
    return text


def post_discord_webhook(
    webhook_url: str,
    message: str,
    *,
    username: Optional[str] = None,
    timeout_seconds: int = 15,
) -> Tuple[bool, str]:
    """Return (ok, info). `info` never contains the webhook URL."""

    payload = {"content": message}
    if username:
        payload["username"] = username

    data = json.dumps(payload, ensure_ascii=True).encode("utf-8")
    req = urllib.request.Request(
        webhook_url,
        data=data,
        headers={
            "Content-Type": "application/json",
            "User-Agent": "openclaw-webhook-promo-scheduler/1.0",
        },
        method="POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
            code = getattr(resp, "status", None) or resp.getcode()
            if 200 <= int(code) < 300:
                return True, f"HTTP {int(code)}"
            return False, f"HTTP {int(code)}"
    except urllib.error.HTTPError as e:
        code = getattr(e, "code", None)
        return False, f"HTTPError {int(code) if code is not None else 'unknown'}"
    except urllib.error.URLError:
        return False, "URLError"
    except Exception as e:
        return False, f"Error {type(e).__name__}"


path: scripts/promo_scheduler.py
content: #!/usr/bin/env python3
"""CLI tool to post/rotate Discord webhook promos with a JSONL anti-spam ledger.

Stdlib only.
"""

from __future__ import annotations

import argparse
import hashlib
import sys
from typing import List, Optional

from ledger import DEFAULT_LEDGER_PATH, Ledger, LedgerEntry, today_local_yyyy_mm_dd
from post_webhook import post_discord_webhook, redact_webhook_url


def eprint(msg: str) -> None:
    sys.stderr.write(msg + "\n")


def sha256_hex(text: str) -> str:
    h = hashlib.sha256()
    h.update(text.encode("utf-8"))
    return h.hexdigest()


def read_messages_file(path: str) -> List[str]:
    out: List[str] = []
    with open(path, "r", encoding="utf-8") as f:
        for raw in f:
            line = raw.strip()
            if not line:
                continue
            if line.startswith("#"):
                continue
            out.append(line)
    return out


def choose_next_message(messages: List[str], last_hash: Optional[str]) -> str:
    if not messages:
        raise ValueError("messages list is empty")
    if last_hash is None:
        return messages[0]

    hashes = [sha256_hex(m) for m in messages]
    try:
        idx = hashes.index(last_hash)
    except ValueError:
        return messages[0]
    return messages[(idx + 1) % len(messages)]


def validate_no_url_print(_: str) -> str:
    return "<redacted>"


def cmd_post(args: argparse.Namespace) -> int:
    ledger = Ledger(args.ledger_path)
    today = today_local_yyyy_mm_dd()

    if ledger.already_posted_today(args.channel, today=today):
        eprint(f"Refusing to post: already posted today for channel={args.channel} date={today}")
        return 3

    message = args.message
    msg_hash = sha256_hex(message)

    ledger.append(LedgerEntry(date=today, channel=args.channel, status="reserved", hash=msg_hash))

    if getattr(args, "dry_run", False):
        ledger.append(LedgerEntry(date=today, channel=args.channel, status="dry-run", hash=msg_hash))
        eprint(
            f"Dry-run (not sent): channel={args.channel} date={today} hash={msg_hash[:12]}... url={validate_no_url_print(args.webhook_url)}"
        )
        return 0

    ok, info = post_discord_webhook(
        args.webhook_url,
        message,
        username=args.username,
        timeout_seconds=args.timeout,
    )

    if ok:
        ledger.append(LedgerEntry(date=today, channel=args.channel, status="posted", hash=msg_hash))
        eprint(
            f"Posted: channel={args.channel} date={today} hash={msg_hash[:12]}... ({info}) url={validate_no_url_print(args.webhook_url)}"
        )
        return 0

    ledger.append(LedgerEntry(date=today, channel=args.channel, status="error", hash=msg_hash))
    eprint(
        f"Post failed: channel={args.channel} date={today} hash={msg_hash[:12]}... ({redact_webhook_url(info)}) url={validate_no_url_print(args.webhook_url)}"
    )
    return 2


def cmd_rotate(args: argparse.Namespace) -> int:
    ledger = Ledger(args.ledger_path)
    today = today_local_yyyy_mm_dd()

    if ledger.already_posted_today(args.channel, today=today):
        eprint(f"Skipping rotate: already posted today for channel={args.channel} date={today}")
        return 0

    messages = read_messages_file(args.messages_file)
    if not messages:
        eprint("No messages found (after filtering blanks/#comments).")
        return 2

    last_hash = ledger.last_posted_hash(args.channel)
    msg = choose_next_message(messages, last_hash)

    post_args = argparse.Namespace(
        webhook_url=args.webhook_url,
        channel=args.channel,
        message=msg,
        ledger_path=args.ledger_path,
        username=args.username,
        timeout=args.timeout,
        dry_run=args.dry_run,
    )
    return cmd_post(post_args)


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="promo_scheduler.py",
        description="Post/rotate Discord webhook promos with a JSONL anti-spam ledger (stdlib only).",
    )
    p.add_argument(
        "--ledger-path",
        default=DEFAULT_LEDGER_PATH,
        help=f"JSONL ledger path (default: {DEFAULT_LEDGER_PATH})",
    )

    sub = p.add_subparsers(dest="cmd", required=True)

    p_post = sub.add_parser("post", help="One-shot post (max 1/day/channel)")
    p_post.add_argument("--webhook-url", required=True, help="Discord webhook URL (never printed)")
    p_post.add_argument("--channel", required=True, help="Logical channel name for ledger enforcement")
    p_post.add_argument("--message", required=True, help="Message content to send")
    p_post.add_argument("--username", default=None, help="Optional Discord webhook username override")
    p_post.add_argument("--timeout", type=int, default=15, help="HTTP timeout in seconds (default: 15)")
    p_post.add_argument("--dry-run", action="store_true", help="Do not send; only write to ledger and print a dry-run log")
    p_post.set_defaults(func=cmd_post)

    p_rot = sub.add_parser("rotate", help="Rotate through a messages file (max 1/day/channel)")
    p_rot.add_argument("--webhook-url", required=True, help="Discord webhook URL (never printed)")
    p_rot.add_argument("--channel", required=True, help="Logical channel name for ledger enforcement")
    p_rot.add_argument("--messages-file", required=True, help="Text file with one message per line")
    p_rot.add_argument("--username", default=None, help="Optional Discord webhook username override")
    p_rot.add_argument("--timeout", type=int, default=15, help="HTTP timeout in seconds (default: 15)")
    p_rot.add_argument("--dry-run", action="store_true", help="Do not send; only write to ledger and print a dry-run log")
    p_rot.set_defaults(func=cmd_rotate)

    return p


def main(argv: List[str]) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))


Security scan verdicts — ClawScan: clean | static analysis:  | VirusTotal: suspicious | SkillSpector: clean (LOW)