feat(wallet-monitor): Slack alerts for low operator wallet balances

A one-shot monitor for the vector-marketplace box: checks each wallet in
wallets.json against a threshold (default 10 APEX) via Ogmios
(ReadOnlyOgmiosProvider) and posts a combined Slack message when any wallet is
below it. Re-reminds every 6h while a wallet stays low and posts a recovery
message when it climbs back above, deduped through a persisted state file.

Runs hourly under host cron via `docker compose run --rm` — no long-running
server, no ports/healthcheck. Reuses the buyer/supplier tsx Docker pattern and
the existing balance-query client; addresses come from a static, gitignored
wallets.json so the monitor never reads a private key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-16 11:00:30 +02:00
parent b2866506fc
commit 3f2a37c157
13 changed files with 617 additions and 1 deletions

6
.gitignore vendored
View File

@@ -11,9 +11,13 @@ coverage
!.env.*.example
# Local data / caches (matches at any depth — covers
# deploy/simulation/data/, indexer/data/, etc.)
# deploy/simulation/data/, indexer/data/, wallet-monitor/data/, etc.)
data/
*.log
# wallet-monitor: the real wallet list holds production mainnet addresses —
# keep it out of git; only wallets.example.json is committed.
wallet-monitor/wallets.json
.vitest-cache
.DS_Store

View File

@@ -0,0 +1,40 @@
# deploy/mainnet/docker-compose.wallet-monitor.yml — Wallet-balance Slack alerter — MAINNET
#
# A ONE-SHOT, not a long-running server: it checks every wallet listed in
# wallet-monitor/wallets.json, posts a Slack alert for any below threshold,
# then exits. There is no port, healthcheck, or restart policy.
#
# Invoked hourly by cron (see /etc/cron.d/wallet-monitor):
# cd /root/agents-marketplace && \
# docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor
#
# One-time setup on the box:
# 1) populate wallet-monitor/wallets.json (the 7 addresses — see the package README)
# 2) create wallet-monitor/.env with SLACK_WEBHOOK_URL=...
# 3) docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml build
# 4) smoke test: docker compose -f ... run --rm -e TEST=1 wallet-monitor
name: marketplace-mainnet-wallet-monitor
services:
wallet-monitor:
build:
context: ../..
dockerfile: wallet-monitor/Dockerfile
env_file:
- path: ../../wallet-monitor/.env
required: false
environment:
# Mainnet-only service; pin the public Ogmios endpoint explicitly.
# (config.ts also defaults to this, but this compose is mainnet-specific.)
OGMIOS_URL: https://ogmios.vector.mainnet.apexfusion.org
volumes:
# The wallet list is read-only. The dedup state dir is writable and
# persists across runs (data/ is gitignored).
- ../../wallet-monitor/wallets.json:/repo/wallet-monitor/wallets.json:ro
- ../../wallet-monitor/data:/repo/wallet-monitor/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

10
pnpm-lock.yaml generated
View File

@@ -290,6 +290,16 @@ importers:
specifier: ^5.7.2
version: 5.9.3
wallet-monitor:
dependencies:
'@marketplace/shared':
specifier: workspace:*
version: link:../packages/shared
devDependencies:
typescript:
specifier: ^5.7.2
version: 5.9.3
packages:
'@adobe/css-tools@4.4.4':

View File

@@ -4,3 +4,4 @@ packages:
- "supplier"
- "indexer"
- "indexer-ui"
- "wallet-monitor"

View File

@@ -0,0 +1,19 @@
# wallet-monitor/.env — copy to wallet-monitor/.env and fill in.
# (.env is gitignored; only this .env.example template is committed.)
# REQUIRED — Slack Incoming Webhook URL alerts are POSTed to.
# Create one at https://api.slack.com/messaging/webhooks
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
# OPTIONAL — Ogmios HTTP endpoint used to read balances.
# Defaults to the mainnet public endpoint (also pinned in the compose file).
# OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
# OPTIONAL — override the wallets.json default threshold / reminder cadence
# without editing the mounted wallets file.
# DEFAULT_MIN_AP3X=10
# REMINDER_HOURS=6
# OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose).
# WALLETS_PATH=/repo/wallet-monitor/wallets.json
# STATE_PATH=/repo/wallet-monitor/data/state.json

70
wallet-monitor/Dockerfile Normal file
View File

@@ -0,0 +1,70 @@
# syntax=docker/dockerfile:1.7
#
# wallet-monitor/Dockerfile — image for the one-shot wallet-balance alerter.
#
# Same multi-stage tsx strategy as supplier/Dockerfile: install workspace deps
# in a builder stage, ship source + node_modules to a slim runtime stage, run
# the entry point with tsx (we do NOT compile ahead-of-time because
# @marketplace/shared exports .ts via package.json#exports — see supplier/Dockerfile).
#
# This is NOT a long-running server: there is no HEALTHCHECK / EXPOSE. It's
# invoked one-shot by cron: `docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor`.
#
# Build context MUST be the repo root so pnpm can resolve workspace deps:
# docker build -f wallet-monitor/Dockerfile -t marketplace-wallet-monitor .
ARG NODE_VERSION=20.19.0
# ─── builder ──────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /repo
# pnpm via corepack — lockfile pins pnpm@10.33.2.
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
# Workspace manifests first to maximise layer cache reuse. pnpm-lock.yaml indexes
# the whole workspace, so every package's manifest must be present even though
# --filter only installs what wallet-monitor needs.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY wallet-monitor/package.json ./wallet-monitor/package.json
COPY buyer/package.json ./buyer/package.json
COPY supplier/package.json ./supplier/package.json
COPY indexer/package.json ./indexer/package.json
# Install only what wallet-monitor needs (+ its transitive workspace deps, i.e.
# @marketplace/shared and the chain stack).
RUN pnpm install --frozen-lockfile --filter '@marketplace/wallet-monitor...'
# libsodium-wrappers-sumo patch — @marketplace/shared/chain re-exports
# LiveOgmiosProvider, which pulls @lucid-evolution/lucid → CML → the broken
# relative `import "./libsodium-sumo.mjs"`. Copy the file into the wrapper's
# expected path. Identical to supplier/Dockerfile. ARCHITECTURE.md §9 #16.
RUN set -e \
&& SRC=$(find node_modules/.pnpm -name "libsodium-sumo.mjs" -path "*/libsodium-sumo/dist/*" 2>/dev/null | head -1) \
&& DST=$(find node_modules/.pnpm -type d -path "*libsodium-wrappers-sumo@*/node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm" 2>/dev/null | head -1) \
&& [ -n "$SRC" ] && [ -n "$DST" ] || (echo "FATAL: libsodium-sumo paths not resolved" && exit 1) \
&& cp "$SRC" "$DST/" \
&& echo "patched libsodium wrapper: $DST/libsodium-sumo.mjs"
# Copy the actual source — shared + wallet-monitor.
COPY packages/shared ./packages/shared
COPY wallet-monitor ./wallet-monitor
# ─── runtime ──────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS runtime
WORKDIR /repo
# tini for signal-forwarding (graceful SIGTERM → process.exit).
RUN apk add --no-cache tini
COPY --from=builder /repo /repo
# Run as root (the default for this image): the one-shot writes its dedup state
# to a bind-mounted host dir (/repo/wallet-monitor/data), which is typically
# root-owned on the box. No ports, no privileges to drop for a server.
ENV NODE_ENV=production
# tsx is hoisted to /repo/node_modules/.bin by pnpm — invoke via the workspace bin.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node_modules/.bin/tsx", "wallet-monitor/src/index.ts"]

49
wallet-monitor/README.md Normal file
View File

@@ -0,0 +1,49 @@
# wallet-monitor
One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box.
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**),
posts a combined Slack message when any wallet is below it, re-reminds every
`reminder_hours` while a wallet stays low, and posts a recovery message when it
climbs back above. Balances are read from Ogmios via
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`.
It is **not** a long-running server: it runs once and exits, invoked hourly by
cron through `docker compose run --rm`. Dedup state lives in
`wallet-monitor/data/state.json` (bind-mounted, gitignored).
## Files
| File | Purpose |
| --- | --- |
| `src/index.ts` | one-shot check + classify + Slack post + state |
| `src/config.ts` | env loading + `wallets.json` parsing/validation |
| `src/slack.ts` | `postSlack()` Incoming-Webhook helper |
| `wallets.json` | the wallet list + thresholds (gitignored; see `wallets.example.json`) |
| `.env` | `SLACK_WEBHOOK_URL` (gitignored; see `.env.example`) |
## Deploy on the box (one-time)
1. **Generate `wallets.json`.** From a laptop with SSH access, run
`pnpm tsx buyer/scripts/monitor-wallets.ts --json` and copy the `address` of
each of the 7 `*@vector-marketplace` rows into `wallet-monitor/wallets.json`
(start from `wallets.example.json`). Per-wallet `min_ap3x` is optional.
2. **Set the webhook.** `cp wallet-monitor/.env.example wallet-monitor/.env` and
fill in `SLACK_WEBHOOK_URL`.
3. **Build.** `docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml build`
4. **Smoke-test the webhook.**
`docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm -e TEST=1 wallet-monitor`
→ expect a "✅ wallet-monitor configured" message in Slack.
5. **Schedule it.** Add `/etc/cron.d/wallet-monitor`:
```cron
0 * * * * root cd /root/agents-marketplace && docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor >> /var/log/wallet-monitor.log 2>&1
```
## Behaviour notes
- **Failures don't alert.** If Ogmios is unreachable or a wallet query errors,
the run logs to stderr and exits non-zero — no Slack noise — and that wallet's
state is left untouched (an Ogmios blip is never mistaken for a recovery).
- **State only advances on a successful post.** A failed Slack POST leaves the
state file unchanged so the alert retries on the next run.

View File

@@ -0,0 +1,16 @@
{
"name": "@marketplace/wallet-monitor",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"check": "tsx src/index.ts"
},
"dependencies": {
"@marketplace/shared": "workspace:*"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,156 @@
/**
* wallet-monitor/src/config.ts — env + wallets-file loading.
*
* Env vars:
* SLACK_WEBHOOK_URL — (required) Slack Incoming Webhook URL to post alerts to.
* OGMIOS_URL — Ogmios HTTP endpoint. Default mainnet public endpoint.
* WALLETS_PATH — path to wallets.json. Default /repo/wallet-monitor/wallets.json.
* STATE_PATH — path to the dedup state file (read/written each run).
* Default /repo/wallet-monitor/data/state.json.
* DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x.
* REMINDER_HOURS — (optional) overrides wallets.json reminder_hours.
* TEST — "1" posts a one-line "configured" message to Slack and exits.
*
* wallets.json owns the wallet list + the default threshold / reminder cadence;
* the env overrides exist only so an operator can retune without editing the
* mounted wallets file.
*/
const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org";
const DEFAULT_WALLETS_PATH = "/repo/wallet-monitor/wallets.json";
const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json";
const FALLBACK_DEFAULT_MIN_AP3X = 10;
const FALLBACK_REMINDER_HOURS = 6;
export interface EnvConfig {
slackWebhookUrl: string;
ogmiosUrl: string;
walletsPath: string;
statePath: string;
/** When set, overrides the wallets-file default_min_ap3x. */
defaultMinAp3xOverride: number | null;
/** When set, overrides the wallets-file reminder_hours. */
reminderHoursOverride: number | null;
testMode: boolean;
}
export interface WalletEntry {
name: string;
address: string;
/** Per-wallet threshold override (APEX). Falls back to the file default. */
minAp3x?: number;
}
export interface WalletsFile {
defaultMinAp3x: number;
reminderHours: number;
wallets: WalletEntry[];
}
function requireField(env: Record<string, string | undefined>, name: string): string {
const v = env[name];
if (v === undefined || v === null || v === "") {
throw new Error(`loadConfig: missing required env var ${name}`);
}
return v;
}
function nonEmpty(env: Record<string, string | undefined>, name: string, fallback: string): string {
const v = env[name];
return v !== undefined && v !== "" ? v : fallback;
}
function parseOptionalPositiveNumber(
raw: string | undefined,
name: string,
): number | null {
if (raw === undefined || raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(`loadConfig: ${name} must be a positive number, got "${raw}"`);
}
return n;
}
export function loadConfig(env: Record<string, string | undefined>): EnvConfig {
const slackWebhookUrl = requireField(env, "SLACK_WEBHOOK_URL");
return {
slackWebhookUrl,
ogmiosUrl: nonEmpty(env, "OGMIOS_URL", DEFAULT_OGMIOS_URL),
walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH),
statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH),
defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"),
reminderHoursOverride: parseOptionalPositiveNumber(env.REMINDER_HOURS, "REMINDER_HOURS"),
testMode: env.TEST === "1",
};
}
// ── wallets.json parsing ─────────────────────────────────────────────────
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function parsePositiveNumberField(raw: unknown, label: string, fallback: number): number {
if (raw === undefined) return fallback;
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
throw new Error(`wallets.json: ${label} must be a positive number`);
}
return raw;
}
/**
* Validate + normalise the parsed wallets.json. Pure (takes already-parsed
* JSON) so it's easy to unit-test. Snake_case JSON keys → camelCase fields.
*/
export function parseWalletsFile(raw: unknown): WalletsFile {
if (!isObject(raw)) {
throw new Error("wallets.json: top-level value must be an object");
}
const defaultMinAp3x = parsePositiveNumberField(
raw.default_min_ap3x,
"default_min_ap3x",
FALLBACK_DEFAULT_MIN_AP3X,
);
const reminderHours = parsePositiveNumberField(
raw.reminder_hours,
"reminder_hours",
FALLBACK_REMINDER_HOURS,
);
const walletsRaw = raw.wallets;
if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) {
throw new Error("wallets.json: 'wallets' must be a non-empty array");
}
const seen = new Set<string>();
const wallets: WalletEntry[] = walletsRaw.map((w, i) => {
if (!isObject(w)) {
throw new Error(`wallets.json: wallets[${i}] must be an object`);
}
const name = w.name;
const address = w.address;
if (typeof name !== "string" || name === "") {
throw new Error(`wallets.json: wallets[${i}].name must be a non-empty string`);
}
if (typeof address !== "string" || !address.startsWith("addr")) {
throw new Error(`wallets.json: wallets[${i}].address must be a bech32 address (addr…)`);
}
if (seen.has(name)) {
throw new Error(`wallets.json: duplicate wallet name "${name}"`);
}
seen.add(name);
const entry: WalletEntry = { name, address };
if (w.min_ap3x !== undefined) {
if (typeof w.min_ap3x !== "number" || !Number.isFinite(w.min_ap3x) || w.min_ap3x <= 0) {
throw new Error(`wallets.json: wallets[${i}].min_ap3x must be a positive number`);
}
entry.minAp3x = w.min_ap3x;
}
return entry;
});
return { defaultMinAp3x, reminderHours, wallets };
}

188
wallet-monitor/src/index.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* wallet-monitor/src/index.ts — one-shot wallet-balance Slack alerter.
*
* Runs once and exits (invoked hourly by cron via `docker compose run --rm`).
* For each wallet in wallets.json: query its on-chain balance via Ogmios, and
* if it's below threshold post a combined Slack alert. A persisted state file
* dedups so a still-low wallet only re-alerts every `reminder_hours`, and a
* recovery message fires when a wallet climbs back above its threshold.
*
* Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same
* client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts.
*/
/* eslint-disable no-console */
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
import { dirname } from "path";
import { ReadOnlyOgmiosProvider } from "@marketplace/shared/chain";
import { loadConfig, parseWalletsFile, type WalletEntry } from "./config.js";
import { postSlack } from "./slack.js";
const LOVELACE_PER_AP3X = 1_000_000;
const MS_PER_HOUR = 3_600_000;
type Status = "ok" | "low";
interface WalletState {
status: Status;
/** Epoch ms of the last alert we successfully posted for this wallet. */
lastAlertMs: number;
}
type StateFile = Record<string, WalletState>;
interface CheckedWallet {
name: string;
address: string;
minLovelace: bigint;
/** null when the balance query failed. */
lovelace: bigint | null;
error: string | null;
}
// ── helpers ───────────────────────────────────────────────────────────────
function ap3xToLovelace(ap3x: number): bigint {
return BigInt(Math.round(ap3x * LOVELACE_PER_AP3X));
}
function fmtAp3x(lovelace: bigint): string {
return (Number(lovelace) / LOVELACE_PER_AP3X).toFixed(2);
}
function shortAddr(a: string): string {
return a.length <= 18 ? a : `${a.slice(0, 10)}…${a.slice(-6)}`;
}
function loadState(path: string): StateFile {
try {
const obj: unknown = JSON.parse(readFileSync(path, "utf-8"));
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return obj as StateFile;
}
return {};
} catch {
// Missing / unreadable / malformed state → start clean.
return {};
}
}
function saveState(path: string, state: StateFile): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
renameSync(tmp, path); // atomic replace
}
// ── main ────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const cfg = loadConfig(process.env);
// TEST mode: confirm the webhook works, then exit.
if (cfg.testMode) {
const r = await postSlack(
cfg.slackWebhookUrl,
"✅ *wallet-monitor configured* — test message from the vector-marketplace box.",
);
if (!r.ok) {
console.error(`[wallet-monitor] test Slack post failed: ${r.error}`);
process.exitCode = 1;
return;
}
console.log("[wallet-monitor] test Slack post ok");
return;
}
const walletsFile = parseWalletsFile(
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
);
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
const reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR;
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
const checked: CheckedWallet[] = await Promise.all(
walletsFile.wallets.map(async (w: WalletEntry): Promise<CheckedWallet> => {
const minLovelace = ap3xToLovelace(w.minAp3x ?? defaultMinAp3x);
try {
const utxos = await provider.queryUtxosByAddress(w.address);
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
return { name: w.name, address: w.address, minLovelace, lovelace, error: null };
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${error}`);
return { name: w.name, address: w.address, minLovelace, lovelace: null, error };
}
}),
);
const anyQueryFailed = checked.some((c) => c.error !== null);
const prevState = loadState(cfg.statePath);
const now = Date.now();
const nextState: StateFile = { ...prevState };
const lowLines: string[] = [];
const recoveredLines: string[] = [];
for (const c of checked) {
// Skip wallets we couldn't query — leave their state untouched so we don't
// mistake an Ogmios blip for a recovery.
if (c.error !== null || c.lovelace === null) continue;
const prev = prevState[c.name];
const isLow = c.lovelace < c.minLovelace;
if (isLow) {
const wasLow = prev?.status === "low";
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs;
if (due) {
lowLines.push(
`🔴 ${c.name} ${fmtAp3x(c.lovelace)} / ${fmtAp3x(c.minLovelace)} APEX ${shortAddr(c.address)}`,
);
nextState[c.name] = { status: "low", lastAlertMs: now };
} else {
// Still low but the reminder window hasn't elapsed — keep prev as-is.
nextState[c.name] = prev ?? { status: "low", lastAlertMs: now };
}
} else {
if (prev?.status === "low") {
recoveredLines.push(`✅ ${c.name} ${fmtAp3x(c.lovelace)} APEX (recovered)`);
}
nextState[c.name] = { status: "ok", lastAlertMs: prev?.lastAlertMs ?? 0 };
}
}
const needPost = lowLines.length > 0 || recoveredLines.length > 0;
if (needPost) {
const title =
lowLines.length > 0
? "⚠️ *Wallet balance alert* — vector-marketplace"
: "✅ *Wallet balance recovered* — vector-marketplace";
const text = [title, ...lowLines, ...recoveredLines].join("\n");
const r = await postSlack(cfg.slackWebhookUrl, text);
if (!r.ok) {
// Leave state untouched so the alert retries next run.
console.error(`[wallet-monitor] Slack post failed (state unchanged): ${r.error}`);
process.exitCode = 1;
return;
}
console.log(`[wallet-monitor] posted: ${lowLines.length} low, ${recoveredLines.length} recovered`);
} else {
console.log("[wallet-monitor] all wallets healthy — no Slack post");
}
// Persist the new state (records ok/low statuses + advanced reminder stamps).
// Only reached when there was nothing to post or the post succeeded.
saveState(cfg.statePath, nextState);
if (anyQueryFailed) process.exitCode = 1;
}
main().catch((e) => {
console.error("[wallet-monitor] fatal:", e instanceof Error ? e.stack ?? e.message : e);
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
/**
* wallet-monitor/src/slack.ts — post a message to a Slack Incoming Webhook.
*
* Never throws: returns { ok, error } so the caller can decide whether to
* advance the dedup state. A failed post leaves state untouched so the alert
* retries on the next run.
*/
export interface SlackResult {
ok: boolean;
error?: string;
}
const TIMEOUT_MS = 15_000;
export async function postSlack(
webhookUrl: string,
text: string,
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
): Promise<SlackResult> {
try {
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
};
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
init.signal = AbortSignal.timeout(TIMEOUT_MS);
}
const res = await fetchImpl(webhookUrl, init);
if (!res.ok) {
const body = await res.text().catch(() => "");
return {
ok: false,
error: `Slack HTTP ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`,
};
}
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,13 @@
{
"default_min_ap3x": 10,
"reminder_hours": 6,
"wallets": [
{ "name": "buyer", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-gpt", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-deepseek", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-kimi", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-kimi-chat","address": "addr1_REPLACE_ME" },
{ "name": "supplier-tts", "address": "addr1_REPLACE_ME", "min_ap3x": 8 }
]
}