Initial commit: OpenClaw ops workspace
This commit is contained in:
125
scripts/backup-openclaw-state.sh
Executable file
125
scripts/backup-openclaw-state.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Backs up key OpenClaw state to a versioned ZIP archive.
|
||||
# Includes:
|
||||
# - ~/.openclaw/openclaw.json
|
||||
# - ~/.openclaw/cron/
|
||||
# - ~/.openclaw/credentials/
|
||||
# - ~/.openclaw/delivery-queue/ (if present)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/backup-openclaw-state.sh [output_dir]
|
||||
# Example:
|
||||
# scripts/backup-openclaw-state.sh /home/node/.openclaw/backups
|
||||
|
||||
OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/.openclaw}"
|
||||
OUTPUT_DIR="${1:-$OPENCLAW_HOME/backups}"
|
||||
TS="$(date -u +"%Y%m%d-%H%M%SZ")"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
get_version() {
|
||||
local raw ver
|
||||
raw="$(openclaw --version 2>/dev/null || true)"
|
||||
if [[ -n "$raw" ]]; then
|
||||
ver="$(printf '%s' "$raw" | head -n1 | sed -E 's/[^0-9A-Za-z._-]+/-/g; s/^-+|-+$//g')"
|
||||
if [[ -n "$ver" ]]; then
|
||||
printf '%s' "$ver"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
raw="$(openclaw status 2>/dev/null | grep -E 'Channel|Update' | head -n1 || true)"
|
||||
ver="$(printf '%s' "$raw" | sed -E 's/[^0-9A-Za-z._-]+/-/g; s/^-+|-+$//g')"
|
||||
if [[ -n "$ver" ]]; then
|
||||
printf '%s' "$ver"
|
||||
else
|
||||
printf 'unknown'
|
||||
fi
|
||||
}
|
||||
|
||||
VERSION="$(get_version)"
|
||||
ARCHIVE_NAME="openclaw-backup-${TS}-v${VERSION}.zip"
|
||||
ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME"
|
||||
|
||||
# Build include list (required + optional)
|
||||
INCLUDE_PATHS=()
|
||||
|
||||
if [[ -f "$OPENCLAW_HOME/openclaw.json" ]]; then
|
||||
INCLUDE_PATHS+=("$OPENCLAW_HOME/openclaw.json")
|
||||
else
|
||||
echo "WARN: Missing $OPENCLAW_HOME/openclaw.json" >&2
|
||||
fi
|
||||
|
||||
if [[ -d "$OPENCLAW_HOME/cron" ]]; then
|
||||
INCLUDE_PATHS+=("$OPENCLAW_HOME/cron")
|
||||
else
|
||||
echo "WARN: Missing $OPENCLAW_HOME/cron" >&2
|
||||
fi
|
||||
|
||||
if [[ -d "$OPENCLAW_HOME/credentials" ]]; then
|
||||
INCLUDE_PATHS+=("$OPENCLAW_HOME/credentials")
|
||||
else
|
||||
echo "WARN: Missing $OPENCLAW_HOME/credentials" >&2
|
||||
fi
|
||||
|
||||
if [[ -d "$OPENCLAW_HOME/delivery-queue" ]]; then
|
||||
INCLUDE_PATHS+=("$OPENCLAW_HOME/delivery-queue")
|
||||
fi
|
||||
|
||||
if [[ ${#INCLUDE_PATHS[@]} -eq 0 ]]; then
|
||||
echo "ERROR: Nothing to back up." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a temp metadata file and include it in the archive.
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
META_FILE="$TMP_DIR/backup-metadata.txt"
|
||||
|
||||
{
|
||||
echo "timestamp_utc=$TS"
|
||||
echo "openclaw_version=$VERSION"
|
||||
echo "openclaw_home=$OPENCLAW_HOME"
|
||||
echo "archive_name=$ARCHIVE_NAME"
|
||||
echo "included_paths="
|
||||
for p in "${INCLUDE_PATHS[@]}"; do
|
||||
echo " - $p"
|
||||
done
|
||||
} > "$META_FILE"
|
||||
|
||||
# Create zip via Python for consistent behavior.
|
||||
python3 - "$ARCHIVE_PATH" "$META_FILE" "${INCLUDE_PATHS[@]}" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
archive = Path(sys.argv[1])
|
||||
meta_file = Path(sys.argv[2])
|
||||
items = [Path(p) for p in sys.argv[3:]]
|
||||
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Put metadata at archive root
|
||||
zf.write(meta_file, arcname="backup-metadata.txt")
|
||||
|
||||
for item in items:
|
||||
if not item.exists():
|
||||
continue
|
||||
|
||||
if item.is_file():
|
||||
zf.write(item, arcname=str(item).lstrip("/"))
|
||||
else:
|
||||
for root, dirs, files in os.walk(item):
|
||||
root_path = Path(root)
|
||||
# preserve empty dirs
|
||||
if not files and not dirs:
|
||||
zi = zipfile.ZipInfo(str(root_path).lstrip("/") + "/")
|
||||
zf.writestr(zi, "")
|
||||
for f in files:
|
||||
fp = root_path / f
|
||||
zf.write(fp, arcname=str(fp).lstrip("/"))
|
||||
PY
|
||||
|
||||
echo "Backup created: $ARCHIVE_PATH"
|
||||
165
scripts/email-review-run.sh
Executable file
165
scripts/email-review-run.sh
Executable file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="/home/node/.openclaw/workspace"
|
||||
STATE_DIR="$ROOT/mail/state"
|
||||
RUN_DIR="$ROOT/mail/runs"
|
||||
STATE_FILE="$STATE_DIR/tasks.json"
|
||||
ACTION_FILE="$STATE_DIR/action-items.json"
|
||||
MAX_ITEMS="${EMAIL_REVIEW_MAX_ITEMS:-10}"
|
||||
WEBHOOK_URL="${N8N_EMAIL_WEBHOOK_URL:-}"
|
||||
NOTIFY="${EMAIL_REVIEW_NOTIFY:-1}"
|
||||
|
||||
mkdir -p "$STATE_DIR" "$RUN_DIR"
|
||||
|
||||
if [[ -z "$WEBHOOK_URL" ]]; then
|
||||
echo "ERROR: N8N_EMAIL_WEBHOOK_URL is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
cat > "$STATE_FILE" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": null,
|
||||
"items": []
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ACTION_FILE" ]]; then
|
||||
cat > "$ACTION_FILE" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": null,
|
||||
"items": []
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
RUN_FILE="$RUN_DIR/run-$RUN_TS.json"
|
||||
TMP_RESP="$(mktemp)"
|
||||
TMP_ITEMS="$(mktemp)"
|
||||
trap 'rm -f "$TMP_RESP" "$TMP_ITEMS"' EXIT
|
||||
|
||||
curl -sS -X POST "$WEBHOOK_URL" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{}' > "$TMP_RESP"
|
||||
|
||||
jq '{fetchedAt: now|todate, response: .}' "$TMP_RESP" > "$RUN_FILE"
|
||||
|
||||
if ! jq -e '.emails? // [] | type == "array"' "$TMP_RESP" >/dev/null 2>&1; then
|
||||
echo "ERROR: n8n response missing emails[] array" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
jq -c '.emails // [] | .[]' "$TMP_RESP" | head -n "$MAX_ITEMS" > "$TMP_ITEMS" || true
|
||||
|
||||
NEW_COUNT=0
|
||||
UPDATED_COUNT=0
|
||||
TOTAL=0
|
||||
|
||||
while IFS= read -r email_json; do
|
||||
[[ -z "$email_json" ]] && continue
|
||||
TOTAL=$((TOTAL+1))
|
||||
|
||||
MESSAGE_ID="$(jq -r '.messageId // empty' <<<"$email_json")"
|
||||
SUBJECT="$(jq -r '.subject // ""' <<<"$email_json")"
|
||||
FROM_ADDR="$(jq -r '.from.value[0].address // .from.text // ""' <<<"$email_json")"
|
||||
RECEIVED_AT="$(jq -r '.date // empty' <<<"$email_json")"
|
||||
BODY="$(jq -r '(.textPlain // .snippet // .textHtml // "") | tostring' <<<"$email_json" | sed 's/\s\+/ /g' | cut -c1-8000)"
|
||||
|
||||
[[ -z "$MESSAGE_ID" ]] && continue
|
||||
|
||||
TRIAGE_INPUT="$(jq -cn \
|
||||
--arg messageId "$MESSAGE_ID" \
|
||||
--arg subject "$SUBJECT" \
|
||||
--arg from "$FROM_ADDR" \
|
||||
--arg receivedAt "$RECEIVED_AT" \
|
||||
--arg body "$BODY" \
|
||||
'{type:"email_review", messageId:$messageId, subject:$subject, from:$from, receivedAt:$receivedAt, body:$body}')"
|
||||
|
||||
TRIAGE_RAW="$(openclaw agent --agent mail-triage --message "$TRIAGE_INPUT" --json 2>/dev/null || true)"
|
||||
TRIAGE_TEXT="$(jq -r '.result.payloads[0].text // empty' <<<"$TRIAGE_RAW" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$TRIAGE_TEXT" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
TRIAGE_JSON="$(jq -c . <<<"$TRIAGE_TEXT" 2>/dev/null || true)"
|
||||
if [[ -z "$TRIAGE_JSON" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
ITEM_KEY="$MESSAGE_ID"
|
||||
EXISTING="$(jq -c --arg k "$ITEM_KEY" '.items[]? | select(.messageId==$k)' "$STATE_FILE")"
|
||||
|
||||
RECORD="$(jq -cn \
|
||||
--arg messageId "$MESSAGE_ID" \
|
||||
--arg updatedAt "$(date -u +%FT%TZ)" \
|
||||
--argjson source "$email_json" \
|
||||
--argjson triage "$TRIAGE_JSON" \
|
||||
'{messageId:$messageId, updatedAt:$updatedAt, source:$source, triage:$triage}')"
|
||||
|
||||
if [[ -z "$EXISTING" ]]; then
|
||||
jq --argjson rec "$RECORD" --arg ts "$(date -u +%FT%TZ)" '.items += [$rec] | .updatedAt=$ts' "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
||||
NEW_COUNT=$((NEW_COUNT+1))
|
||||
|
||||
SUMMARY="$(jq -r '.summary // "(no summary)"' <<<"$TRIAGE_JSON")"
|
||||
echo "NEW ACTIONABLE EMAIL: $SUBJECT"
|
||||
echo "- from: $FROM_ADDR"
|
||||
echo "- summary: $SUMMARY"
|
||||
else
|
||||
OLD_HASH="$(jq -Sc '.triage' <<<"$EXISTING" | sha256sum | awk '{print $1}')"
|
||||
NEW_HASH="$(jq -Sc . <<<"$TRIAGE_JSON" | sha256sum | awk '{print $1}')"
|
||||
|
||||
if [[ "$OLD_HASH" != "$NEW_HASH" ]]; then
|
||||
jq --arg k "$ITEM_KEY" --argjson rec "$RECORD" --arg ts "$(date -u +%FT%TZ)" '
|
||||
.items = (.items | map(if .messageId==$k then $rec else . end))
|
||||
| .updatedAt=$ts
|
||||
' "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
||||
UPDATED_COUNT=$((UPDATED_COUNT+1))
|
||||
echo "UPDATED EMAIL TRIAGE: $SUBJECT"
|
||||
fi
|
||||
fi
|
||||
|
||||
done < "$TMP_ITEMS"
|
||||
|
||||
# Build normalized action list for downstream reminder/overview use.
|
||||
jq --arg ts "$(date -u +%FT%TZ)" '
|
||||
. as $root
|
||||
| {
|
||||
schemaVersion: 1,
|
||||
updatedAt: $ts,
|
||||
items: [
|
||||
$root.items[]?
|
||||
| {
|
||||
messageId,
|
||||
emailSubject: (.source.subject // ""),
|
||||
from: (.source.from.value[0].address // .source.from.text // ""),
|
||||
receivedAt: (.source.date // null),
|
||||
priority: (.triage.priority // "unknown"),
|
||||
urgency: (.triage.urgency // "unknown"),
|
||||
no_action_needed: (.triage.no_action_needed // false),
|
||||
summary: (.triage.summary // ""),
|
||||
actions: (
|
||||
[(.triage.actions // [])[]? | {
|
||||
action: (.action // ""),
|
||||
owner: (.owner // "Ben"),
|
||||
deadline: (.deadline // null),
|
||||
estimated: (.estimated // false),
|
||||
evidence: (.evidence // "")
|
||||
}]
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
' "$STATE_FILE" > "$ACTION_FILE"
|
||||
|
||||
SUMMARY_LINE="email-review-run complete: total=$TOTAL new=$NEW_COUNT updated=$UPDATED_COUNT"
|
||||
echo "$SUMMARY_LINE"
|
||||
|
||||
if [[ "$NOTIFY" == "1" && $((NEW_COUNT + UPDATED_COUNT)) -gt 0 ]]; then
|
||||
openclaw system event --text "Email review: $NEW_COUNT new, $UPDATED_COUNT updated actionable item(s)." --mode now >/dev/null 2>&1 || true
|
||||
fi
|
||||
135
scripts/export-openclaw-host-facts.sh
Executable file
135
scripts/export-openclaw-host-facts.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Generates a sanitized JSON snapshot of an OpenClaw container deployment.
|
||||
# Safe-by-default: excludes environment values, secrets, mounts with secret-looking
|
||||
# paths, and full labels/annotations. Intended for sharing with the agent.
|
||||
#
|
||||
# Usage:
|
||||
# ./export-openclaw-host-facts.sh <container_name_or_id> [output.json]
|
||||
# Example:
|
||||
# ./export-openclaw-host-facts.sh openclaw ./openclaw-host-facts.json
|
||||
|
||||
CONTAINER="${1:-}"
|
||||
OUT="${2:-./openclaw-host-facts.json}"
|
||||
|
||||
if [[ -z "$CONTAINER" ]]; then
|
||||
echo "Usage: $0 <container_name_or_id> [output.json]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP=$(mktemp)
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
docker inspect "$CONTAINER" > "$TMP"
|
||||
|
||||
node - <<'NODE' "$TMP" "$OUT"
|
||||
const fs = require('fs');
|
||||
const inFile = process.argv[2];
|
||||
const outFile = process.argv[3];
|
||||
const data = JSON.parse(fs.readFileSync(inFile, 'utf8'));
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('No inspect data found');
|
||||
}
|
||||
const c = data[0];
|
||||
|
||||
const secretish = /(secret|token|key|passwd|password|cookie|session|credential|auth|oauth|api[-_]?key|webhook)/i;
|
||||
const sensitivePath = /(secret|secrets|private|\.ssh|gnupg|aws|gcloud|kube|\.env|credentials?)/i;
|
||||
|
||||
function uniq(arr) {
|
||||
return [...new Set(arr.filter(Boolean))];
|
||||
}
|
||||
|
||||
function safeMounts(mounts) {
|
||||
return (mounts || [])
|
||||
.filter(m => !(sensitivePath.test(m.Source || '') || sensitivePath.test(m.Destination || '')))
|
||||
.map(m => ({
|
||||
type: m.Type,
|
||||
source: m.Source,
|
||||
destination: m.Destination,
|
||||
mode: m.Mode || '',
|
||||
rw: !!m.RW,
|
||||
}));
|
||||
}
|
||||
|
||||
function safeEnv(env) {
|
||||
const out = {};
|
||||
for (const entry of env || []) {
|
||||
const idx = entry.indexOf('=');
|
||||
const key = idx === -1 ? entry : entry.slice(0, idx);
|
||||
const value = idx === -1 ? '' : entry.slice(idx + 1);
|
||||
if (secretish.test(key)) continue;
|
||||
|
||||
// Keep only clearly non-sensitive runtime facts.
|
||||
if (/^(NODE_ENV|TZ|HOSTNAME|OPENCLAW_VARIANT|OPENCLAW_INSTALL_BROWSER|OPENCLAW_INSTALL_DOCKER_CLI)$/i.test(key)) {
|
||||
out[key] = value;
|
||||
} else {
|
||||
out[key] = '<set>';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const networkSettings = c.NetworkSettings || {};
|
||||
const hostConfig = c.HostConfig || {};
|
||||
const config = c.Config || {};
|
||||
const ports = [];
|
||||
for (const [containerPort, bindings] of Object.entries(networkSettings.Ports || {})) {
|
||||
if (!bindings) {
|
||||
ports.push({ container: containerPort, published: null });
|
||||
continue;
|
||||
}
|
||||
for (const b of bindings) {
|
||||
ports.push({
|
||||
container: containerPort,
|
||||
hostIp: b.HostIp,
|
||||
hostPort: b.HostPort,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: 'docker inspect (sanitized)',
|
||||
deploymentHint: 'komodo-or-docker',
|
||||
container: {
|
||||
name: (c.Name || '').replace(/^\//, ''),
|
||||
idShort: (c.Id || '').slice(0, 12),
|
||||
image: config.Image || null,
|
||||
entrypoint: config.Entrypoint || null,
|
||||
cmd: config.Cmd || null,
|
||||
user: config.User || null,
|
||||
workingDir: config.WorkingDir || null,
|
||||
},
|
||||
runtime: {
|
||||
running: c.State?.Running || false,
|
||||
status: c.State?.Status || null,
|
||||
startedAt: c.State?.StartedAt || null,
|
||||
restartPolicy: hostConfig.RestartPolicy?.Name || null,
|
||||
privileged: !!hostConfig.Privileged,
|
||||
networkMode: hostConfig.NetworkMode || null,
|
||||
pidMode: hostConfig.PidMode || null,
|
||||
ipcMode: hostConfig.IpcMode || null,
|
||||
readOnlyRootfs: !!hostConfig.ReadonlyRootfs,
|
||||
},
|
||||
ports,
|
||||
mounts: safeMounts(c.Mounts),
|
||||
networks: Object.keys(networkSettings.Networks || {}),
|
||||
aliases: uniq(Object.values(networkSettings.Networks || {}).flatMap(n => n.Aliases || [])),
|
||||
env: safeEnv(config.Env),
|
||||
labels: Object.fromEntries(
|
||||
Object.entries(config.Labels || {}).filter(([k]) => {
|
||||
const lk = String(k).toLowerCase();
|
||||
return lk.startsWith('com.komodo.') || lk.startsWith('komodo.') || lk.startsWith('com.docker.compose.');
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
fs.writeFileSync(outFile, JSON.stringify(summary, null, 2));
|
||||
console.log(`Wrote sanitized facts to ${outFile}`);
|
||||
NODE
|
||||
244
scripts/export-openclaw-runtime-facts.sh
Executable file
244
scripts/export-openclaw-runtime-facts.sh
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# v2: Export sanitized OpenClaw runtime facts from Docker + optional Komodo API.
|
||||
# Safe by default:
|
||||
# - no secret env var values
|
||||
# - no obvious secret-ish mount paths
|
||||
# - no raw Komodo payload dump
|
||||
# - only selected/sanitized metadata is emitted
|
||||
#
|
||||
# Usage:
|
||||
# ./export-openclaw-runtime-facts.sh <container_name_or_id> [output.json]
|
||||
#
|
||||
# Optional env:
|
||||
# KOMODO_URL=https://komodo.example/api
|
||||
# KOMODO_TOKEN=read_only_token
|
||||
# KOMODO_STACK_NAME=openclaw
|
||||
# KOMODO_RESOURCE_ID=optional-id
|
||||
# KOMODO_VERIFY_TLS=true|false (default: true)
|
||||
#
|
||||
# Example:
|
||||
# KOMODO_URL=https://komodo.example/api \
|
||||
# KOMODO_TOKEN=... \
|
||||
# KOMODO_STACK_NAME=openclaw \
|
||||
# ./export-openclaw-runtime-facts.sh openclaw ./openclaw-runtime-facts.json
|
||||
|
||||
CONTAINER="${1:-}"
|
||||
OUT="${2:-./openclaw-runtime-facts.json}"
|
||||
|
||||
if [[ -z "$CONTAINER" ]]; then
|
||||
echo "Usage: $0 <container_name_or_id> [output.json]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "node not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOCKER_JSON=$(mktemp)
|
||||
KOMODO_JSON=$(mktemp)
|
||||
trap 'rm -f "$DOCKER_JSON" "$KOMODO_JSON"' EXIT
|
||||
|
||||
docker inspect "$CONTAINER" > "$DOCKER_JSON"
|
||||
|
||||
KOMODO_STATUS="not_configured"
|
||||
if [[ -n "${KOMODO_URL:-}" && -n "${KOMODO_TOKEN:-}" ]]; then
|
||||
CURL_OPTS=(-sS -H "Authorization: Bearer ${KOMODO_TOKEN}" -H "Accept: application/json")
|
||||
if [[ "${KOMODO_VERIFY_TLS:-true}" == "false" ]]; then
|
||||
CURL_OPTS+=(-k)
|
||||
fi
|
||||
|
||||
# Try a few likely read-only endpoints/patterns without assuming one exact API shape.
|
||||
# The Node merger below is tolerant of missing/unexpected payloads.
|
||||
{
|
||||
echo '{"attempts":['
|
||||
first=1
|
||||
for path in \
|
||||
"/api/stacks" \
|
||||
"/api/stack" \
|
||||
"/api/deployments" \
|
||||
"/api/deployment" \
|
||||
"/api/resources"; do
|
||||
if resp=$(curl "${CURL_OPTS[@]}" "${KOMODO_URL%/}${path}" 2>/dev/null); then
|
||||
[[ $first -eq 0 ]] && echo ','
|
||||
first=0
|
||||
printf '{"path":%s,"body":%s}' "$(node -p 'JSON.stringify(process.argv[1])' "$path")" "$(printf '%s' "$resp" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(s)))')"
|
||||
fi
|
||||
done
|
||||
echo ']}'
|
||||
} > "$KOMODO_JSON" || true
|
||||
|
||||
if [[ -s "$KOMODO_JSON" ]]; then
|
||||
KOMODO_STATUS="queried"
|
||||
else
|
||||
KOMODO_STATUS="query_failed"
|
||||
fi
|
||||
else
|
||||
echo '{"attempts":[]}' > "$KOMODO_JSON"
|
||||
fi
|
||||
|
||||
node - <<'NODE' "$DOCKER_JSON" "$KOMODO_JSON" "$OUT" "$KOMODO_STATUS" "${KOMODO_STACK_NAME:-}" "${KOMODO_RESOURCE_ID:-}"
|
||||
const fs = require('fs');
|
||||
|
||||
const dockerFile = process.argv[2];
|
||||
const komodoFile = process.argv[3];
|
||||
const outFile = process.argv[4];
|
||||
const komodoStatus = process.argv[5];
|
||||
const hintedStackName = process.argv[6] || null;
|
||||
const hintedResourceId = process.argv[7] || null;
|
||||
|
||||
const dockerData = JSON.parse(fs.readFileSync(dockerFile, 'utf8'));
|
||||
if (!Array.isArray(dockerData) || dockerData.length === 0) throw new Error('No docker inspect data');
|
||||
const c = dockerData[0];
|
||||
const komodoRaw = JSON.parse(fs.readFileSync(komodoFile, 'utf8'));
|
||||
|
||||
const secretish = /(secret|token|key|passwd|password|cookie|session|credential|auth|oauth|api[-_]?key|webhook)/i;
|
||||
const sensitivePath = /(secret|secrets|private|\.ssh|gnupg|aws|gcloud|kube|\.env|credentials?)/i;
|
||||
|
||||
function uniq(arr) { return [...new Set((arr || []).filter(Boolean))]; }
|
||||
function short(v, n=12) { return typeof v === 'string' ? v.slice(0, n) : v; }
|
||||
function isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); }
|
||||
|
||||
function safeMounts(mounts) {
|
||||
return (mounts || [])
|
||||
.filter(m => !(sensitivePath.test(m.Source || '') || sensitivePath.test(m.Destination || '')))
|
||||
.map(m => ({
|
||||
type: m.Type,
|
||||
source: m.Source,
|
||||
destination: m.Destination,
|
||||
mode: m.Mode || '',
|
||||
rw: !!m.RW,
|
||||
}));
|
||||
}
|
||||
|
||||
function safeEnv(env) {
|
||||
const out = {};
|
||||
for (const entry of env || []) {
|
||||
const idx = entry.indexOf('=');
|
||||
const key = idx === -1 ? entry : entry.slice(0, idx);
|
||||
const value = idx === -1 ? '' : entry.slice(idx + 1);
|
||||
if (secretish.test(key)) continue;
|
||||
if (/^(NODE_ENV|TZ|HOSTNAME|OPENCLAW_VARIANT|OPENCLAW_INSTALL_BROWSER|OPENCLAW_INSTALL_DOCKER_CLI)$/i.test(key)) {
|
||||
out[key] = value;
|
||||
} else {
|
||||
out[key] = '<set>';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeLabels(labels) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(labels || {}).filter(([k, v]) => {
|
||||
const lk = String(k).toLowerCase();
|
||||
if (secretish.test(lk)) return false;
|
||||
return lk.startsWith('com.komodo.') || lk.startsWith('komodo.') || lk.startsWith('com.docker.compose.');
|
||||
}).map(([k, v]) => [k, String(v)])
|
||||
);
|
||||
}
|
||||
|
||||
function parseIfJson(s) {
|
||||
try { return JSON.parse(s); } catch { return s; }
|
||||
}
|
||||
|
||||
function walkForCandidates(node, acc = []) {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walkForCandidates(item, acc);
|
||||
return acc;
|
||||
}
|
||||
if (!isObj(node)) return acc;
|
||||
|
||||
const lowerKeys = Object.keys(node).map(k => k.toLowerCase());
|
||||
const joined = lowerKeys.join(' ');
|
||||
if (/(stack|deployment|service|container|compose|docker)/.test(joined)) acc.push(node);
|
||||
|
||||
for (const v of Object.values(node)) walkForCandidates(v, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
function summarizeKomodo(raw) {
|
||||
const attempts = raw.attempts || [];
|
||||
const parsedBodies = attempts.map(a => ({ path: a.path, body: parseIfJson(a.body) }));
|
||||
const candidates = parsedBodies.flatMap(a => walkForCandidates(a.body, []));
|
||||
|
||||
const textBlob = JSON.stringify(parsedBodies).toLowerCase();
|
||||
|
||||
const guessed = {
|
||||
stackName: hintedStackName,
|
||||
resourceId: hintedResourceId,
|
||||
sourcePaths: attempts.map(a => a.path),
|
||||
apiReachable: attempts.length > 0,
|
||||
hints: {
|
||||
mentionsOpenclaw: /openclaw/.test(textBlob),
|
||||
mentionsKomodo: /komodo/.test(textBlob),
|
||||
mentionsCompose: /compose/.test(textBlob),
|
||||
},
|
||||
matchedObjects: candidates.slice(0, 10).map(obj => {
|
||||
const entries = Object.entries(obj)
|
||||
.filter(([k, v]) => !secretish.test(k) && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
|
||||
.slice(0, 20);
|
||||
return Object.fromEntries(entries);
|
||||
}),
|
||||
};
|
||||
|
||||
return guessed;
|
||||
}
|
||||
|
||||
const networkSettings = c.NetworkSettings || {};
|
||||
const hostConfig = c.HostConfig || {};
|
||||
const config = c.Config || {};
|
||||
const ports = [];
|
||||
for (const [containerPort, bindings] of Object.entries(networkSettings.Ports || {})) {
|
||||
if (!bindings) {
|
||||
ports.push({ container: containerPort, published: null });
|
||||
continue;
|
||||
}
|
||||
for (const b of bindings) {
|
||||
ports.push({ container: containerPort, hostIp: b.HostIp, hostPort: b.HostPort });
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: 'docker inspect + optional Komodo API (sanitized)',
|
||||
container: {
|
||||
name: (c.Name || '').replace(/^\//, ''),
|
||||
idShort: short(c.Id, 12),
|
||||
image: config.Image || null,
|
||||
entrypoint: config.Entrypoint || null,
|
||||
cmd: config.Cmd || null,
|
||||
user: config.User || null,
|
||||
workingDir: config.WorkingDir || null,
|
||||
},
|
||||
runtime: {
|
||||
running: c.State?.Running || false,
|
||||
status: c.State?.Status || null,
|
||||
startedAt: c.State?.StartedAt || null,
|
||||
restartPolicy: hostConfig.RestartPolicy?.Name || null,
|
||||
privileged: !!hostConfig.Privileged,
|
||||
networkMode: hostConfig.NetworkMode || null,
|
||||
pidMode: hostConfig.PidMode || null,
|
||||
ipcMode: hostConfig.IpcMode || null,
|
||||
readOnlyRootfs: !!hostConfig.ReadonlyRootfs,
|
||||
},
|
||||
ports,
|
||||
mounts: safeMounts(c.Mounts),
|
||||
networks: Object.keys(networkSettings.Networks || {}),
|
||||
aliases: uniq(Object.values(networkSettings.Networks || {}).flatMap(n => n.Aliases || [])),
|
||||
env: safeEnv(config.Env),
|
||||
labels: safeLabels(config.Labels),
|
||||
komodo: {
|
||||
status: komodoStatus,
|
||||
...summarizeKomodo(komodoRaw),
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(outFile, JSON.stringify(summary, null, 2));
|
||||
console.log(`Wrote sanitized runtime facts to ${outFile}`);
|
||||
NODE
|
||||
100
scripts/gmail-unread-poll.sh
Executable file
100
scripts/gmail-unread-poll.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
STATE_DIR="${STATE_DIR:-/home/node/.openclaw/workspace/state}"
|
||||
STATE_FILE="${STATE_FILE:-$STATE_DIR/gmail-unread-state.json}"
|
||||
ACCOUNT="${ACCOUNT:-claw@ben.io}"
|
||||
MAX_RESULTS="${MAX_RESULTS:-10}"
|
||||
OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}"
|
||||
CHANNEL="${CHANNEL:-discord}"
|
||||
DEST="${DEST:-channel:1467247377743347953}"
|
||||
ACCOUNT_ID="${ACCOUNT_ID:-default}"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
TMP_LIST=$(mktemp)
|
||||
trap 'rm -f "$TMP_LIST"' EXIT
|
||||
|
||||
if ! gws gmail users messages list --params "{\"userId\":\"me\",\"maxResults\":$MAX_RESULTS,\"q\":\"is:unread\"}" --format json > "$TMP_LIST" 2>/dev/null; then
|
||||
echo "gmail-unread-poll: failed to query Gmail unread messages" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_IDS=$(node -e '
|
||||
const fs = require("fs");
|
||||
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const ids = (data.messages || []).map(m => m.id).sort();
|
||||
process.stdout.write(JSON.stringify(ids));
|
||||
' "$TMP_LIST")
|
||||
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
PREV_IDS=$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
process.stdout.write(JSON.stringify((data.unreadIds || []).sort()));
|
||||
' "$STATE_FILE")
|
||||
else
|
||||
PREV_IDS='[]'
|
||||
fi
|
||||
|
||||
if [[ "$CURRENT_IDS" == "$PREV_IDS" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NEW_IDS=$(node -e '
|
||||
const prev = new Set(JSON.parse(process.argv[1]));
|
||||
const curr = JSON.parse(process.argv[2]);
|
||||
process.stdout.write(JSON.stringify(curr.filter(id => !prev.has(id))));
|
||||
' "$PREV_IDS" "$CURRENT_IDS")
|
||||
|
||||
NEW_COUNT=$(node -e 'const a = JSON.parse(process.argv[1]); process.stdout.write(String(a.length));' "$NEW_IDS")
|
||||
TOTAL_UNREAD=$(node -e 'const a = JSON.parse(process.argv[1]); process.stdout.write(String(a.length));' "$CURRENT_IDS")
|
||||
|
||||
if [[ "$NEW_COUNT" -gt 0 ]]; then
|
||||
SUMMARY=$(node - <<'NODE' "$NEW_IDS"
|
||||
const ids = JSON.parse(process.argv[2]);
|
||||
console.log(`claw@ben.io: ${ids.length} new unread mail(s)`);
|
||||
NODE
|
||||
)
|
||||
|
||||
DETAILS=()
|
||||
while IFS= read -r id; do
|
||||
[[ -z "$id" ]] && continue
|
||||
JSON=$(gws gmail +read --message-id "$id" --format json 2>/dev/null || true)
|
||||
if [[ -n "$JSON" ]]; then
|
||||
LINE=$(node -e '
|
||||
const fs = require("fs");
|
||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const from = data.from?.email || data.from?.name || "unknown";
|
||||
const subject = data.subject || "(no subject)";
|
||||
process.stdout.write(`- ${from} — ${subject}`);
|
||||
' <<<"$JSON")
|
||||
DETAILS+=("$LINE")
|
||||
fi
|
||||
done < <(node -e 'for (const id of JSON.parse(process.argv[1])) console.log(id)' "$NEW_IDS")
|
||||
|
||||
MSG="$SUMMARY
|
||||
Total unread: $TOTAL_UNREAD"
|
||||
if [[ ${#DETAILS[@]} -gt 0 ]]; then
|
||||
MSG+="
|
||||
$(printf '%s
|
||||
' "${DETAILS[@]}")"
|
||||
fi
|
||||
|
||||
"$OPENCLAW_BIN" message send \
|
||||
--account "$ACCOUNT_ID" \
|
||||
--channel "$CHANNEL" \
|
||||
--target "$DEST" \
|
||||
--message "$MSG" >/dev/null 2>&1 || {
|
||||
echo "gmail-unread-poll: failed to send notification" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
node - <<'NODE' "$STATE_FILE" "$CURRENT_IDS"
|
||||
const fs = require('fs');
|
||||
const path = process.argv[2];
|
||||
const unreadIds = JSON.parse(process.argv[3]);
|
||||
fs.writeFileSync(path, JSON.stringify({ unreadIds, updatedAt: new Date().toISOString() }, null, 2));
|
||||
NODE
|
||||
48
scripts/google-drift-audit.py
Executable file
48
scripts/google-drift-audit.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
STATE_PATH = Path('/home/node/.openclaw/workspace/state/projects.json')
|
||||
|
||||
|
||||
def parse_ts(ts: str | None):
|
||||
if not ts:
|
||||
return None
|
||||
if ts.endswith('Z'):
|
||||
ts = ts[:-1] + '+00:00'
|
||||
return datetime.fromisoformat(ts)
|
||||
|
||||
|
||||
def main():
|
||||
state = json.loads(STATE_PATH.read_text())
|
||||
now = datetime.now(timezone.utc)
|
||||
stale = []
|
||||
missing_next_action = []
|
||||
missing_tasks = []
|
||||
|
||||
for project in state.get('projects', []):
|
||||
if project.get('status') != 'open':
|
||||
continue
|
||||
if not project.get('next_action'):
|
||||
missing_next_action.append(project['title'])
|
||||
if not project.get('task_ids'):
|
||||
missing_tasks.append(project['title'])
|
||||
last = parse_ts(project.get('last_activity_at'))
|
||||
if last and (now - last).days >= 14:
|
||||
stale.append({
|
||||
'title': project['title'],
|
||||
'days_since_update': (now - last).days,
|
||||
'last_activity_at': project.get('last_activity_at')
|
||||
})
|
||||
|
||||
print(json.dumps({
|
||||
'generated_at': now.isoformat(),
|
||||
'stale_projects': stale,
|
||||
'missing_next_action': missing_next_action,
|
||||
'missing_tasks': missing_tasks
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
166
scripts/google-sync.py
Executable file
166
scripts/google-sync.py
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
WORKSPACE = Path('/home/node/.openclaw/workspace')
|
||||
STATE_PATH = WORKSPACE / 'state' / 'projects.json'
|
||||
REMINDER_LIST = Path('/home/node/.openclaw/skills/reminder/scripts/list.sh')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reminder:
|
||||
when_local: str
|
||||
message: str
|
||||
reminder_id: str
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> str:
|
||||
res = subprocess.run(cmd, text=True, capture_output=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError((res.stderr or res.stdout).strip())
|
||||
return res.stdout
|
||||
|
||||
|
||||
def load_state() -> dict[str, Any]:
|
||||
return json.loads(STATE_PATH.read_text())
|
||||
|
||||
|
||||
def save_state(state: dict[str, Any]) -> None:
|
||||
STATE_PATH.write_text(json.dumps(state, indent=2) + "\n")
|
||||
|
||||
|
||||
def slug(text: str) -> str:
|
||||
return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
|
||||
|
||||
|
||||
def parse_reminders() -> list[Reminder]:
|
||||
out = run(['bash', str(REMINDER_LIST)])
|
||||
reminders: list[Reminder] = []
|
||||
current_when = None
|
||||
current_msg = None
|
||||
current_id = None
|
||||
for line in out.splitlines():
|
||||
if line.startswith('⏰ '):
|
||||
current_when = line.replace('⏰ ', '', 1).strip()
|
||||
current_msg = None
|
||||
current_id = None
|
||||
elif line.strip().startswith('ID:'):
|
||||
current_id = line.split('ID:', 1)[1].strip()
|
||||
if current_when and current_msg and current_id:
|
||||
reminders.append(Reminder(when_local=current_when, message=current_msg, reminder_id=current_id))
|
||||
current_when = None
|
||||
current_msg = None
|
||||
current_id = None
|
||||
elif current_when and line.startswith(' ') and current_msg is None and line.strip() and not line.strip().startswith('ID:'):
|
||||
current_msg = line.strip()
|
||||
return reminders
|
||||
|
||||
|
||||
def ensure_followup_task(state: dict[str, Any], title: str, notes: str = '') -> str:
|
||||
tasklist = state['google']['tasklists']['claw_follow_ups']['id']
|
||||
out = run([
|
||||
'gws', 'tasks', 'tasks', 'insert',
|
||||
'--params', json.dumps({'tasklist': tasklist}),
|
||||
'--json', json.dumps({'title': title, 'notes': notes})
|
||||
])
|
||||
obj = json.loads(out)
|
||||
return obj['id']
|
||||
|
||||
|
||||
def ensure_calendar_event(state: dict[str, Any], summary: str, start_utc: str, end_utc: str, description: str = '') -> str:
|
||||
cal_id = state['google']['calendar']['claw_ops']['id']
|
||||
existing_raw = run([
|
||||
'gws', 'calendar', 'events', 'list',
|
||||
'--params', json.dumps({'calendarId': cal_id})
|
||||
])
|
||||
existing = json.loads(existing_raw)
|
||||
for item in existing.get('items', []):
|
||||
if item.get('summary') == summary and item.get('start', {}).get('dateTime') == start_utc:
|
||||
return item['id']
|
||||
out = run([
|
||||
'gws', 'calendar', 'events', 'insert',
|
||||
'--params', json.dumps({'calendarId': cal_id}),
|
||||
'--json', json.dumps({
|
||||
'summary': summary,
|
||||
'description': description,
|
||||
'start': {'dateTime': start_utc, 'timeZone': 'UTC'},
|
||||
'end': {'dateTime': end_utc, 'timeZone': 'UTC'}
|
||||
})
|
||||
])
|
||||
obj = json.loads(out)
|
||||
return obj['id']
|
||||
|
||||
|
||||
def sync_projects(state: dict[str, Any]) -> dict[str, Any]:
|
||||
project_list_id = state['google']['tasklists']['claw_projects']['id']
|
||||
created = []
|
||||
for project in state['projects']:
|
||||
ids = set(project.get('task_ids', []))
|
||||
if not ids:
|
||||
title = f"[Project] {project['title']}"
|
||||
notes = f"Project ID: {project['id']}\nStatus: {project['status']}\nNext action: {project.get('next_action', '')}\nNotes ref: {project.get('notes_ref', '')}"
|
||||
out = run([
|
||||
'gws', 'tasks', 'tasks', 'insert',
|
||||
'--params', json.dumps({'tasklist': project_list_id}),
|
||||
'--json', json.dumps({'title': title, 'notes': notes})
|
||||
])
|
||||
obj = json.loads(out)
|
||||
project.setdefault('task_ids', []).append(obj['id'])
|
||||
created.append({'type': 'project-task', 'project_id': project['id'], 'task_id': obj['id']})
|
||||
if project.get('next_action') and len(project.get('task_ids', [])) < 2:
|
||||
title = f"[{project['title']}] {project['next_action']}"
|
||||
notes = f"Project ID: {project['id']}\nSource: {project.get('notes_ref', '')}"
|
||||
out = run([
|
||||
'gws', 'tasks', 'tasks', 'insert',
|
||||
'--params', json.dumps({'tasklist': project_list_id}),
|
||||
'--json', json.dumps({'title': title, 'notes': notes})
|
||||
])
|
||||
obj = json.loads(out)
|
||||
project.setdefault('task_ids', []).append(obj['id'])
|
||||
created.append({'type': 'next-action-task', 'project_id': project['id'], 'task_id': obj['id']})
|
||||
return {'created': created}
|
||||
|
||||
|
||||
def sync_reminders(state: dict[str, Any]) -> dict[str, Any]:
|
||||
reminder_state = state.setdefault('reminders', {'synced': {}})
|
||||
synced = reminder_state.setdefault('synced', {})
|
||||
created = []
|
||||
skipped = []
|
||||
for rem in parse_reminders():
|
||||
if rem.reminder_id in synced:
|
||||
continue
|
||||
msg = rem.message.replace('\\n', '\n')
|
||||
lower = msg.lower()
|
||||
if 'vehicle registration renewal' in lower:
|
||||
# known time conversion from existing reminder local labels
|
||||
if '09:00 cdt' in rem.when_local.lower():
|
||||
start, end = '2026-04-15T14:00:00Z', '2026-04-15T14:30:00Z'
|
||||
elif '16:00 cdt' in rem.when_local.lower():
|
||||
start, end = '2026-04-15T21:00:00Z', '2026-04-15T21:30:00Z'
|
||||
else:
|
||||
skipped.append({'reminder_id': rem.reminder_id, 'reason': 'unsupported-known-reminder-time'})
|
||||
continue
|
||||
event_id = ensure_calendar_event(state, 'Vehicle registration renewal - Tesla Model Y (VJF3166)', start, end, msg)
|
||||
synced[rem.reminder_id] = {'kind': 'calendar', 'event_id': event_id}
|
||||
created.append({'reminder_id': rem.reminder_id, 'calendar_event_id': event_id})
|
||||
else:
|
||||
skipped.append({'reminder_id': rem.reminder_id, 'reason': 'past-or-unscheduled-manual-review'})
|
||||
return {'created': created, 'skipped': skipped}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
state = load_state()
|
||||
result = {
|
||||
'projects': sync_projects(state),
|
||||
'reminders': sync_reminders(state),
|
||||
}
|
||||
save_state(state)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
208
scripts/resolve-channel-names.sh
Executable file
208
scripts/resolve-channel-names.sh
Executable file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REG_JSON="/home/node/.openclaw/memory/channel-registry.json"
|
||||
REG_MD="/home/node/.openclaw/memory/channel-registry.md"
|
||||
OVERRIDES_JSON="/home/node/.openclaw/memory/channel-name-overrides.json"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
reg_path = Path('/home/node/.openclaw/memory/channel-registry.json')
|
||||
md_path = Path('/home/node/.openclaw/memory/channel-registry.md')
|
||||
overrides_path = Path('/home/node/.openclaw/memory/channel-name-overrides.json')
|
||||
|
||||
reg = json.loads(reg_path.read_text())
|
||||
entries = reg.get('entries', [])
|
||||
idx = {(e['platform'], e['kind'], e['id']): e for e in entries}
|
||||
|
||||
obs = {}
|
||||
|
||||
def note(cid, key, value):
|
||||
if not cid or not value:
|
||||
return
|
||||
obs.setdefault(cid, {})[key] = value
|
||||
|
||||
# 1) Explicit overrides win (manual curated)
|
||||
if overrides_path.exists():
|
||||
try:
|
||||
ov = json.loads(overrides_path.read_text())
|
||||
for cid, data in (ov.get('discord', {}) or {}).items():
|
||||
if isinstance(data, dict):
|
||||
for k in ('guild_name','channel_name','thread_name','guild_id'):
|
||||
if data.get(k):
|
||||
note(cid, k, data[k])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure override IDs are represented even if not referenced yet
|
||||
for cid, data in (ov.get('discord', {}) or {}).items() if 'ov' in locals() else []:
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
kind = 'guild' if data.get('guild_name') and not data.get('channel_name') and not data.get('thread_name') else 'channel'
|
||||
key = ('discord', kind, cid)
|
||||
if key not in idx:
|
||||
entries.append({
|
||||
'platform': 'discord',
|
||||
'kind': kind,
|
||||
'id': cid,
|
||||
'guild_id': data.get('guild_id') or (cid if kind == 'guild' else None),
|
||||
'guild_name': data.get('guild_name'),
|
||||
'channel_name': data.get('channel_name'),
|
||||
'thread_name': data.get('thread_name'),
|
||||
'agent_owner': None,
|
||||
'used_by': ['override:manual'],
|
||||
'purpose': 'manual override registry seed',
|
||||
'status': 'active' if (data.get('guild_name') or data.get('channel_name') or data.get('thread_name')) else 'unresolved',
|
||||
'last_verified_utc': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
})
|
||||
idx[key] = entries[-1]
|
||||
|
||||
# 2) Scan transcripts for embedded metadata (bounded to prevent huge-context blowups)
|
||||
roots = [
|
||||
Path('/home/node/.openclaw/workspace'),
|
||||
Path('/home/node/.openclaw/workspace-home'),
|
||||
Path('/home/node/.openclaw/workspace-security'),
|
||||
Path('/home/node/.openclaw/workspace-research'),
|
||||
]
|
||||
|
||||
MAX_JSONL_FILES = 400
|
||||
MAX_FILE_SCAN_BYTES = 1_000_000
|
||||
MAX_TOTAL_SCAN_BYTES = 25_000_000
|
||||
|
||||
pat_discord = re.compile(r'discord:(\d+)#([A-Za-z0-9_-]+)')
|
||||
pat_conv = re.compile(r'channel id:(\d+)')
|
||||
pat_group_channel = re.compile(r'"group_channel"\s*:\s*"(#?[^"]+)"')
|
||||
pat_thread = re.compile(r'"thread_label"\s*:\s*"Discord thread\s+#([^›"]+)\s+›\s+([^"]+)"')
|
||||
pat_subject = re.compile(r'"group_subject"\s*:\s*"(#?[^"]+)"')
|
||||
|
||||
def bounded_read_jsonl(path: Path, limit_bytes: int) -> str:
|
||||
size = path.stat().st_size
|
||||
with path.open('rb') as fh:
|
||||
if size <= limit_bytes:
|
||||
data = fh.read(limit_bytes)
|
||||
else:
|
||||
head = fh.read(limit_bytes // 2)
|
||||
fh.seek(max(0, size - (limit_bytes // 2)))
|
||||
tail = fh.read(limit_bytes // 2)
|
||||
data = head + b'\n...TRUNCATED...\n' + tail
|
||||
return data.decode('utf-8', errors='ignore')
|
||||
|
||||
jsonl_paths = []
|
||||
for root in roots:
|
||||
if root.exists():
|
||||
jsonl_paths.extend(root.rglob('*.jsonl'))
|
||||
|
||||
bytes_scanned = 0
|
||||
for p in sorted(jsonl_paths)[:MAX_JSONL_FILES]:
|
||||
if bytes_scanned >= MAX_TOTAL_SCAN_BYTES:
|
||||
break
|
||||
budget_left = MAX_TOTAL_SCAN_BYTES - bytes_scanned
|
||||
per_file_cap = min(MAX_FILE_SCAN_BYTES, budget_left)
|
||||
if per_file_cap <= 0:
|
||||
break
|
||||
try:
|
||||
txt = bounded_read_jsonl(p, per_file_cap)
|
||||
except Exception:
|
||||
continue
|
||||
bytes_scanned += len(txt.encode('utf-8', errors='ignore'))
|
||||
|
||||
# pattern: discord:<id>#name
|
||||
for m in pat_discord.finditer(txt):
|
||||
cid, cname = m.group(1), m.group(2)
|
||||
if not cname.startswith('#'):
|
||||
cname = '#' + cname
|
||||
note(cid, 'channel_name', cname)
|
||||
|
||||
# conversation metadata blocks
|
||||
for m in pat_conv.finditer(txt):
|
||||
cid = m.group(1)
|
||||
window = txt[max(0, m.start()-1200): m.end()+1200]
|
||||
gm = pat_group_channel.search(window)
|
||||
if gm:
|
||||
cname = gm.group(1)
|
||||
if cname and not cname.startswith('#'):
|
||||
cname = '#' + cname
|
||||
note(cid, 'channel_name', cname)
|
||||
sm = pat_subject.search(window)
|
||||
if sm and not obs.get(cid, {}).get('channel_name'):
|
||||
sname = sm.group(1)
|
||||
if sname and not sname.startswith('#'):
|
||||
sname = '#' + sname
|
||||
note(cid, 'channel_name', sname)
|
||||
tm = pat_thread.search(window)
|
||||
if tm:
|
||||
# forum-ish parent and thread label
|
||||
forum = tm.group(1).strip()
|
||||
tname = tm.group(2).strip()
|
||||
if forum:
|
||||
if not forum.startswith('#'):
|
||||
forum = '#' + forum
|
||||
note(cid, 'channel_name', forum)
|
||||
note(cid, 'thread_name', tname)
|
||||
|
||||
# 3) Apply observations to registry
|
||||
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
changed = 0
|
||||
for e in entries:
|
||||
if e.get('platform') != 'discord':
|
||||
continue
|
||||
cid = e.get('id')
|
||||
data = obs.get(cid, {})
|
||||
before = json.dumps(e, sort_keys=True)
|
||||
|
||||
for k in ('guild_id','guild_name','channel_name','thread_name'):
|
||||
if data.get(k) and not e.get(k):
|
||||
e[k] = data[k]
|
||||
|
||||
# status rule
|
||||
if e.get('kind') == 'guild':
|
||||
e['status'] = 'active' if e.get('guild_name') else 'unresolved'
|
||||
else:
|
||||
e['status'] = 'active' if (e.get('channel_name') or e.get('thread_name')) else 'unresolved'
|
||||
|
||||
e['last_verified_utc'] = now
|
||||
after = json.dumps(e, sort_keys=True)
|
||||
if before != after:
|
||||
changed += 1
|
||||
|
||||
reg['updated_utc'] = now
|
||||
reg_path.write_text(json.dumps(reg, indent=2) + '\n')
|
||||
|
||||
# 4) Render markdown table from JSON
|
||||
lines = []
|
||||
lines.append('# Channel Registry')
|
||||
lines.append('')
|
||||
lines.append('Global ID→name registry for cron delivery targets and routing bindings.')
|
||||
lines.append('')
|
||||
lines.append('## Resolution Policy')
|
||||
lines.append('- IDs are canonical; names are metadata and may drift.')
|
||||
lines.append('- Auto-resolution uses transcript/session metadata + optional overrides file.')
|
||||
lines.append('- Any referenced entry with `status: unresolved` must be manually resolved.')
|
||||
lines.append('')
|
||||
lines.append('## Entries')
|
||||
lines.append('')
|
||||
lines.append('| Platform | Kind | ID | Guild ID | Guild Name | Channel Name | Thread Name | Agent Owner | Status | Used By |')
|
||||
lines.append('|---|---|---|---|---|---|---|---|---|---|')
|
||||
for e in sorted(entries, key=lambda x: (x['platform'], x['kind'], x['id'])):
|
||||
lines.append(
|
||||
f"| {e.get('platform','')} | {e.get('kind','')} | `{e.get('id','')}` | `{e.get('guild_id') or ''}` | {e.get('guild_name') or 'UNRESOLVED'} | {e.get('channel_name') or 'UNRESOLVED'} | {e.get('thread_name') or ''} | {e.get('agent_owner') or ''} | {e.get('status') or ''} | {'; '.join(e.get('used_by',[]))} |"
|
||||
)
|
||||
|
||||
lines.append('')
|
||||
lines.append('## Unresolved IDs')
|
||||
for e in entries:
|
||||
if e.get('status') == 'unresolved':
|
||||
lines.append(f"- `{e.get('kind')}:{e.get('id')}` (agent `{e.get('agent_owner')}`)")
|
||||
|
||||
lines.append('')
|
||||
lines.append('## Manual Resolution')
|
||||
lines.append('1. Add/patch explicit values in `/home/node/.openclaw/memory/channel-name-overrides.json`.')
|
||||
lines.append('2. Re-run `scripts/resolve-channel-names.sh` to merge overrides + observations.')
|
||||
lines.append('3. Run `scripts/validate-channel-registry.sh` and ensure it returns `OK`.')
|
||||
|
||||
md_path.write_text('\n'.join(lines) + '\n')
|
||||
print(f'Updated registry. Changed entries: {changed}')
|
||||
PY
|
||||
28
scripts/safe-jsonl-peek.sh
Executable file
28
scripts/safe-jsonl-peek.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <jsonl-file> [max-bytes-per-file]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE="$1"
|
||||
MAX_BYTES="${2:-1048576}"
|
||||
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "error: file not found: $FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE=$(wc -c < "$FILE")
|
||||
|
||||
if [[ "$SIZE" -le "$MAX_BYTES" ]]; then
|
||||
cat "$FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HALF=$(( MAX_BYTES / 2 ))
|
||||
|
||||
head -c "$HALF" "$FILE"
|
||||
printf '\n...TRUNCATED...\n'
|
||||
tail -c "$HALF" "$FILE"
|
||||
76
scripts/validate-channel-registry.sh
Executable file
76
scripts/validate-channel-registry.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REG_JSON="/home/node/.openclaw/memory/channel-registry.json"
|
||||
CONF_JSON="/home/node/.openclaw/openclaw.json"
|
||||
CRON_JSON="/home/node/.openclaw/cron/jobs.json"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
reg = json.loads(Path('/home/node/.openclaw/memory/channel-registry.json').read_text())
|
||||
conf = json.loads(Path('/home/node/.openclaw/openclaw.json').read_text())
|
||||
jobs = json.loads(Path('/home/node/.openclaw/cron/jobs.json').read_text())['jobs']
|
||||
|
||||
index={(e['platform'],e['kind'],e['id']):e for e in reg.get('entries',[])}
|
||||
errors=[]
|
||||
|
||||
# check bindings
|
||||
for b in conf.get('bindings',[]):
|
||||
m=b.get('match',{})
|
||||
if m.get('channel')!='discord':
|
||||
continue
|
||||
gid=m.get('guildId')
|
||||
if gid:
|
||||
k=('discord','guild',gid)
|
||||
if k not in index:
|
||||
errors.append(f"Missing registry entry for binding guild:{gid}")
|
||||
peer=m.get('peer') or {}
|
||||
pid=peer.get('id'); kind=peer.get('kind')
|
||||
if pid and kind in ('channel','group'):
|
||||
k=('discord',kind,pid)
|
||||
if k not in index:
|
||||
errors.append(f"Missing registry entry for binding {kind}:{pid}")
|
||||
|
||||
# check cron delivery targets
|
||||
for j in jobs:
|
||||
d=j.get('delivery',{})
|
||||
to=d.get('to')
|
||||
if not isinstance(to,str):
|
||||
continue
|
||||
cid=None
|
||||
if to.startswith('channel:'): cid=to.split(':',1)[1]; kind='channel'
|
||||
elif to.isdigit(): cid=to; kind='channel'
|
||||
else: continue
|
||||
k=('discord',kind,cid)
|
||||
if k not in index:
|
||||
errors.append(f"Missing registry entry for cron {j['id']} target {kind}:{cid}")
|
||||
|
||||
# unresolved check for referenced entries
|
||||
referenced=set()
|
||||
for b in conf.get('bindings',[]):
|
||||
m=b.get('match',{})
|
||||
if m.get('channel')!='discord': continue
|
||||
gid=m.get('guildId')
|
||||
if gid: referenced.add(('discord','guild',gid))
|
||||
peer=m.get('peer') or {}
|
||||
pid=peer.get('id'); kind=peer.get('kind')
|
||||
if pid and kind in ('channel','group'): referenced.add(('discord',kind,pid))
|
||||
for j in jobs:
|
||||
d=j.get('delivery',{})
|
||||
to=d.get('to')
|
||||
if not isinstance(to,str): continue
|
||||
if to.startswith('channel:'): referenced.add(('discord','channel',to.split(':',1)[1]))
|
||||
elif to.isdigit(): referenced.add(('discord','channel',to))
|
||||
|
||||
for k in sorted(referenced):
|
||||
e=index.get(k)
|
||||
if e and e.get('status')=='unresolved':
|
||||
errors.append(f"Unresolved referenced ID: {k[1]}:{k[2]}")
|
||||
|
||||
if errors:
|
||||
print('CHANNEL REGISTRY VALIDATION: FAIL')
|
||||
for err in errors:
|
||||
print('-', err)
|
||||
sys.exit(1)
|
||||
print('CHANNEL REGISTRY VALIDATION: OK')
|
||||
PY
|
||||
73
scripts/verify-discord-routing.sh
Executable file
73
scripts/verify-discord-routing.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONF="/home/node/.openclaw/openclaw.json"
|
||||
CONTRACT="/home/node/.openclaw/workspace/memory/discord-routing-contract.json"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
|
||||
conf=json.loads(Path('/home/node/.openclaw/openclaw.json').read_text())
|
||||
contract=json.loads(Path('/home/node/.openclaw/workspace/memory/discord-routing-contract.json').read_text())
|
||||
|
||||
errors=[]
|
||||
warn=[]
|
||||
|
||||
bindings=conf.get('bindings',[])
|
||||
discord_cfg=((conf.get('channels') or {}).get('discord') or {})
|
||||
guilds=(discord_cfg.get('guilds') or {})
|
||||
gid=contract.get('guildId')
|
||||
guild_cfg=(guilds.get(gid) or {})
|
||||
guild_channels=(guild_cfg.get('channels') or {})
|
||||
|
||||
# index bindings by (agent, kind, id)
|
||||
def has_binding(agent,kind,cid):
|
||||
for b in bindings:
|
||||
if b.get('agentId')!=agent: continue
|
||||
m=b.get('match') or {}
|
||||
if m.get('channel')!='discord': continue
|
||||
p=m.get('peer') or {}
|
||||
if p.get('kind')==kind and p.get('id')==cid:
|
||||
return True
|
||||
return False
|
||||
|
||||
# validate channel contracts
|
||||
for cid,meta in (contract.get('channels') or {}).items():
|
||||
agent=meta.get('expectedAgent')
|
||||
kinds=meta.get('peerKinds') or []
|
||||
for k in kinds:
|
||||
if not has_binding(agent,k,cid):
|
||||
errors.append(f"Missing binding: agent={agent} peer={k}:{cid}")
|
||||
# allowlist presence
|
||||
if cid not in guild_channels:
|
||||
errors.append(f"Channel {cid} missing from channels.discord.guilds.{gid}.channels allowlist")
|
||||
else:
|
||||
req=(guild_channels.get(cid) or {}).get('requireMention')
|
||||
exp=meta.get('requireMention')
|
||||
if exp is not None and req!=exp:
|
||||
errors.append(f"requireMention mismatch for {cid}: expected {exp}, got {req}")
|
||||
|
||||
# validate guild fallback
|
||||
fb=((contract.get('guildFallback') or {}).get('expectedAgent'))
|
||||
if fb:
|
||||
ok=False
|
||||
for b in bindings:
|
||||
if b.get('agentId')!=fb: continue
|
||||
m=b.get('match') or {}
|
||||
if m.get('channel')=='discord' and m.get('guildId')==gid:
|
||||
ok=True
|
||||
break
|
||||
if not ok:
|
||||
errors.append(f"Missing guild fallback binding for guild {gid} -> {fb}")
|
||||
|
||||
# Note: binding index order does not override match-tier precedence (peer beats guild).
|
||||
# So we intentionally do not fail on peer-after-guild placement.
|
||||
|
||||
if errors:
|
||||
print('DISCORD ROUTING VERIFICATION: FAIL')
|
||||
for e in errors:
|
||||
print('-',e)
|
||||
sys.exit(1)
|
||||
print('DISCORD ROUTING VERIFICATION: OK')
|
||||
PY
|
||||
Reference in New Issue
Block a user