"""Loiter plugin for Slack bots/apps (Bolt for Python or raw API). Adds a sponsored context line to your agent's status/progress messages in Slack, and reports display time. Fail-safe: errors never break your bot. Usage with Bolt: from loiter_slack import LoiterSlack sb = LoiterSlack.load() # after one-time `register` blocks = sb.decorate_blocks(blocks) # appends a context block (or no-op) ... post/update your status message ... sb.report(display_seconds) # when the status message is replaced Plain-text fallback: text = sb.decorate_text(text) # appends "\\n_Sponsored · ..._" One-time setup: python3 loiter_slack.py register [--server URL] [--ref CODE] [--out PATH] """ import json, os, sys, time, urllib.request DEFAULT_SERVER = "http://127.0.0.1:8787" DEFAULT_CONF = os.path.expanduser("~/.loiter/slack_bot.json") SECONDS_PER_IMPRESSION = 5 REFRESH_S = 600 def _api(server, method, path, body=None, params=None, timeout=4): url = server + path if params: from urllib.parse import urlencode url += "?" + urlencode(params) data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read()) class LoiterSlack: def __init__(self, server, device_key): self.server = server self.device_key = device_key self._ads = [] self._fetched = 0.0 self._i = 0 self._shown = {} @classmethod def load(cls, path=DEFAULT_CONF): with open(path) as f: c = json.load(f) return cls(c["server"], c["device_key"]) def _next(self): try: if time.time() - self._fetched > REFRESH_S or not self._ads: r = _api(self.server, "GET", "/api/serve", params={"device_key": self.device_key, "n": 6}) self._ads = r.get("ads", []) self._fetched = time.time() if not self._ads: return None ad = self._ads[self._i % len(self._ads)] self._i += 1 self._shown[ad["ad_id"]] = self._shown.get(ad["ad_id"], 0) + 1 return ad except Exception: return None def decorate_blocks(self, blocks): """Append a Slack context block with the sponsored line. No-op on failure.""" ad = self._next() if not ad: return blocks txt = f"Sponsored · {ad['text']}" if ad.get("url"): url = ad["url"] if ad["url"].startswith("http") else "https://" + ad["url"] txt = f"Sponsored · <{url}|{ad['text']}>" return list(blocks) + [{"type": "context", "elements": [{"type": "mrkdwn", "text": txt}]}] def decorate_text(self, text): ad = self._next() if not ad: return text line = f"_Sponsored · {ad['text']}" if ad.get("url"): line += f" → {ad['url']}" return f"{text}\n{line}_" def report(self, display_seconds): try: total = max(0, int(display_seconds)) // SECONDS_PER_IMPRESSION if total <= 0 or not self._shown: self._shown = {} return weights = sum(self._shown.values()) items, left = [], total shown = sorted(self._shown.items()) for j, (ad_id, w) in enumerate(shown): n = total * w // weights if j < len(shown) - 1 else left n = min(n, left) if n > 0: items.append({"ad_id": ad_id, "count": n}) left -= n self._shown = {} if items: _api(self.server, "POST", "/api/impressions", {"device_key": self.device_key, "items": items, "active_seconds": int(display_seconds)}) except Exception: self._shown = {} def _register(argv): server = DEFAULT_SERVER; ref = None; out = DEFAULT_CONF if "--server" in argv: server = argv[argv.index("--server") + 1] if "--ref" in argv: ref = argv[argv.index("--ref") + 1] if "--out" in argv: out = argv[argv.index("--out") + 1] r = _api(server, "POST", "/api/register", {"platform": "slack-bot", "ref": ref}, timeout=10) os.makedirs(os.path.dirname(out), exist_ok=True) with open(out, "w") as f: json.dump({"server": server, "device_key": r["device_key"], "ref_code": r["ref_code"]}, f, indent=2) os.chmod(out, 0o600) print(f"Registered. Config: {out}\nReferral code: {r['ref_code']}") if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "register": _register(sys.argv[2:]) else: print(__doc__)