Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Executable
+1086
File diff suppressed because it is too large
Load Diff
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply Gmail labels for owner mailbox automation.
|
||||
|
||||
Currently classifies self-sent briefing mails into existing/created Gmail labels
|
||||
such as "브리핑" and "주식". Designed for OpenClaw cron at 01:00 Asia/Seoul.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
ACCOUNT = "mini.snowoyh@gmail.com"
|
||||
STATE_DIR = Path(__file__).resolve().parents[1] / "state"
|
||||
LOG_PATH = STATE_DIR / "gmail_label_classify.log"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
name: str
|
||||
label: str
|
||||
query: str
|
||||
remove_labels: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CleanupRule:
|
||||
name: str
|
||||
query: str
|
||||
|
||||
|
||||
RULES = [
|
||||
Rule(
|
||||
name="briefing",
|
||||
label="브리핑",
|
||||
query='newer_than:7d subject:("[오전 브리핑]" OR "[오후 브리핑]")',
|
||||
),
|
||||
Rule(
|
||||
name="stock_analysis",
|
||||
label="종목분석",
|
||||
query='newer_than:30d subject:"[비하이브 종목분석]"',
|
||||
remove_labels=("주식",),
|
||||
),
|
||||
Rule(
|
||||
name="stock_briefing",
|
||||
label="주식브리핑",
|
||||
query='newer_than:30d subject:"[주식 리포트]"',
|
||||
remove_labels=("주식",),
|
||||
),
|
||||
]
|
||||
|
||||
CLEANUP_RULES = [
|
||||
CleanupRule(
|
||||
name="test_mail_24h",
|
||||
query=(
|
||||
'older_than:1d -in:trash '
|
||||
'from:mini.snowoyh@gmail.com to:mini.snowoyh@gmail.com '
|
||||
'(subject:테스트 OR subject:test OR subject:TEST)'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_gog(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
cmd = ["gog", "gmail", *args, "--account", ACCOUNT, "--no-input"]
|
||||
return subprocess.run(cmd, text=True, capture_output=True, check=check)
|
||||
|
||||
|
||||
def list_labels() -> set[str]:
|
||||
cp = run_gog(["labels", "list", "--json"])
|
||||
data = json.loads(cp.stdout or "{}")
|
||||
labels = data.get("labels") or data.get("Labels") or []
|
||||
names: set[str] = set()
|
||||
for item in labels:
|
||||
if isinstance(item, dict):
|
||||
name = item.get("name") or item.get("Name")
|
||||
if name:
|
||||
names.add(name)
|
||||
if not names:
|
||||
# Fallback for older gog JSON shapes: parse plain output.
|
||||
cp = run_gog(["labels", "list", "--plain"])
|
||||
for line in cp.stdout.splitlines()[1:]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
names.add(parts[1])
|
||||
return names
|
||||
|
||||
|
||||
def ensure_label(label: str, dry_run: bool) -> None:
|
||||
if label in list_labels():
|
||||
return
|
||||
if dry_run:
|
||||
print(f"[dry-run] create label: {label}")
|
||||
return
|
||||
run_gog(["labels", "create", label])
|
||||
|
||||
|
||||
def search_threads(query: str) -> list[dict]:
|
||||
cp = run_gog(["search", query, "--max", "100", "--json"])
|
||||
data = json.loads(cp.stdout or "{}")
|
||||
return data.get("threads") or []
|
||||
|
||||
|
||||
def has_label(thread: dict, label: str) -> bool:
|
||||
labels = thread.get("labels") or []
|
||||
return label in labels
|
||||
|
||||
|
||||
def apply_label(thread_id: str, label: str, dry_run: bool) -> None:
|
||||
if dry_run:
|
||||
print(f"[dry-run] add {label} to thread {thread_id}")
|
||||
return
|
||||
run_gog(["labels", "modify", thread_id, "--add", label])
|
||||
|
||||
|
||||
def remove_label(thread_id: str, label: str, dry_run: bool) -> None:
|
||||
if dry_run:
|
||||
print(f"[dry-run] remove {label} from thread {thread_id}")
|
||||
return
|
||||
run_gog(["labels", "modify", thread_id, "--remove", label])
|
||||
|
||||
|
||||
def log(line: str) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
with LOG_PATH.open("a", encoding="utf-8") as f:
|
||||
f.write(f"{now} {line}\n")
|
||||
|
||||
|
||||
def cleanup_messages(dry_run: bool = False) -> tuple[int, int]:
|
||||
total_seen = 0
|
||||
total_trashed = 0
|
||||
for rule in CLEANUP_RULES:
|
||||
threads = search_threads(rule.query)
|
||||
total_seen += len(threads)
|
||||
if dry_run:
|
||||
for thread in threads:
|
||||
print(f"[dry-run] trash {rule.name}: {thread.get('id')} {thread.get('subject')}")
|
||||
elif threads:
|
||||
run_gog(["trash", "--query", rule.query, "--max", "100"])
|
||||
total_trashed += len(threads)
|
||||
print(f"{rule.name}: seen={len(threads)} trashed={0 if dry_run else len(threads)}")
|
||||
return total_seen, total_trashed
|
||||
|
||||
|
||||
def classify(dry_run: bool = False) -> int:
|
||||
total_seen = 0
|
||||
total_changed = 0
|
||||
for rule in RULES:
|
||||
ensure_label(rule.label, dry_run)
|
||||
threads = search_threads(rule.query)
|
||||
total_seen += len(threads)
|
||||
changed = 0
|
||||
for thread in threads:
|
||||
thread_id = thread.get("id")
|
||||
if not thread_id:
|
||||
continue
|
||||
if not has_label(thread, rule.label):
|
||||
apply_label(thread_id, rule.label, dry_run)
|
||||
changed += 1
|
||||
for old_label in rule.remove_labels:
|
||||
if has_label(thread, old_label):
|
||||
remove_label(thread_id, old_label, dry_run)
|
||||
changed += 1
|
||||
total_changed += changed
|
||||
print(f"{rule.name}: seen={len(threads)} changed={changed}")
|
||||
|
||||
cleanup_seen, cleanup_trashed = cleanup_messages(dry_run=dry_run)
|
||||
status = "dry-run" if dry_run else "ok"
|
||||
summary = (
|
||||
f"status={status} seen={total_seen} labeled={total_changed} "
|
||||
f"cleanup_seen={cleanup_seen} cleanup_trashed={0 if dry_run else cleanup_trashed}"
|
||||
)
|
||||
print(summary)
|
||||
if not dry_run:
|
||||
log(summary)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return classify(dry_run=args.dry_run)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(e.stdout, end="", file=sys.stderr)
|
||||
print(e.stderr, end="", file=sys.stderr)
|
||||
return e.returncode or 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
\"""
|
||||
Claude Code Remote Control 세션 관리 툴 (다중 세션 + 프로필 CRUD). 데이터 모델:
|
||||
- profile: 이름 ↔ workdir 매핑.. ~/openclaw/state/claude_sessions.json에 저장.
|
||||
- session: profile 기반으로 떠있는 클로드 데몬. plist 파일 존재 여부가 곧 세션 등록.
|
||||
- ↑ ↓ ↓ 클로드 코드를 직접 조정—구현 전에 ‘빈 번호’ 개념을 명세에 추가해야합니다.
|
||||
\""
|
||||
import re, os, json, argparse
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
OPENCLAW_ROOT = HOME / '.openclaw'
|
||||
LA_DIR = HOME / 'Library' / 'LaunchAgents'
|
||||
CLAIUDE_BIN = HOME / '.local' / 'bin' / 'claude'
|
||||
|
||||
def _write_plist(profile, n, workdir):
|
||||
LA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
label = f'ai.claude-session.{profile}-{n}'
|
||||
plist = LA_DIR / f'{label}.plist'
|
||||
plist.write_text(f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"" http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>{label}</string>
|
||||
<key>ProgramArguments</key><array>
|
||||
<string>{CLAIUDE_BIN}</string>
|
||||
<string>remote-control</string>
|
||||
<string>--name</string>
|
||||
<string>{profile}-{n}</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>{workdir}</string>
|
||||
<key>RunAtLoad</key><false/>
|
||||
<key>KeepAlive</key><false/>
|
||||
<key>ProcessType</key><string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
''')
|
||||
return plist
|
||||
|
||||
print("session_tool.py scritto")
|
||||
Reference in New Issue
Block a user