initial: gitea-bot — mochi 全站 issue/PR 自动处理
systemd --user unit, polls famzheng.me/gitea every 60s, dispatches `claude --dangerously-skip-permissions -p ...` per new activity. sqlite state, log to ~/.local/state/gitea-bot/bot.log.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.log
|
||||||
|
state.db
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.PHONY: install uninstall restart status logs test-once
|
||||||
|
|
||||||
|
UNIT_DIR := $(HOME)/.config/systemd/user
|
||||||
|
STATE_DIR := $(HOME)/.local/state/gitea-bot
|
||||||
|
|
||||||
|
install:
|
||||||
|
@test -f $(HOME)/.gitea-mochi-token || (echo "missing $(HOME)/.gitea-mochi-token"; exit 1)
|
||||||
|
@test -x /home/fam/.local/bin/claude || (echo "missing claude bin"; exit 1)
|
||||||
|
mkdir -p $(UNIT_DIR) $(STATE_DIR)
|
||||||
|
install -m 644 gitea-bot.service $(UNIT_DIR)/gitea-bot.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now gitea-bot
|
||||||
|
@sleep 1
|
||||||
|
systemctl --user status gitea-bot --no-pager -l | head -25
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
-systemctl --user disable --now gitea-bot
|
||||||
|
rm -f $(UNIT_DIR)/gitea-bot.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
restart:
|
||||||
|
systemctl --user restart gitea-bot
|
||||||
|
systemctl --user status gitea-bot --no-pager -l | head -10
|
||||||
|
|
||||||
|
status:
|
||||||
|
systemctl --user status gitea-bot --no-pager -l | head -25
|
||||||
|
|
||||||
|
logs:
|
||||||
|
tail -n 200 -f $(STATE_DIR)/bot.log
|
||||||
|
|
||||||
|
test-once:
|
||||||
|
@# 单次轮询:reset state.db 让所有未处理 issue 都被 dispatch(慎用)
|
||||||
|
MOCHI_TOKEN=$$(cat $(HOME)/.gitea-mochi-token) python3 -c \
|
||||||
|
"import bot; bot.poll_once(bot.init_state(bot.CONFIG['state_dir']/'state.db'), open('$(HOME)/.gitea-mochi-token').read().strip())"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# gitea-bot
|
||||||
|
|
||||||
|
麻薯(mochi)的全站 issue/PR 自动处理 bot——轮询 `famzheng.me/gitea` 所有 repo
|
||||||
|
的 open issue 和 PR,遇到"最新一条活动不是 mochi 自己"就 spawn
|
||||||
|
`claude --dangerously-skip-permissions -p <prompt>`,让 claude 自己用 mochi
|
||||||
|
token 调 gitea API + git push 完成回复 / 提 PR。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
跑在 famzheng.me 节点本机,systemd `--user` unit。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make install # 装到 ~/.config/systemd/user/,enable + start
|
||||||
|
make logs # tail -f 日志
|
||||||
|
make restart # 改完 bot.py 重启
|
||||||
|
make uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
- `~/.gitea-mochi-token` (mode 600,scopes=all)
|
||||||
|
- `/home/fam/.local/bin/claude`
|
||||||
|
- python3 (stdlib only)
|
||||||
|
|
||||||
|
## 行为
|
||||||
|
|
||||||
|
`bot.py` 主循环每 60s:
|
||||||
|
1. `GET /api/v1/repos/search` 拉所有 repo(mochi 是 admin 全部可见)
|
||||||
|
2. 对每个 repo,list open issues + open PRs
|
||||||
|
3. 看每条最新活动的作者:是 `mochi` 就跳过;其他人就 dispatch
|
||||||
|
4. dispatch = 把 repo / issue / 最近 10 条 comment 拼进 prompt,spawn claude
|
||||||
|
5. claude 自己分类 → 留 comment / 提 PR / review;同语言回复,带 footer 签名
|
||||||
|
|
||||||
|
state 在 `~/.local/state/gitea-bot/state.db`(sqlite,`(repo#num) → last_signal_time`)。
|
||||||
|
日志在 `~/.local/state/gitea-bot/bot.log`(systemd append)。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
- `bot.py` — 主循环
|
||||||
|
- `gitea-bot.service` — systemd --user unit(PATH 含 `~/.local/bin`、`~/.cargo/bin`)
|
||||||
|
- `Makefile` — install / restart / logs
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""gitea-bot — mochi 全站 issue/PR 自动处理。
|
||||||
|
|
||||||
|
轮询 famzheng.me/gitea 所有 repo 的 open issue/PR;
|
||||||
|
每条"最新活动不是 mochi 自己"的条目,spawn 一个
|
||||||
|
`claude --dangerously-skip-permissions -p <prompt>`,
|
||||||
|
让 claude 用 mochi token 直接调 gitea API + git push 来回复 / 提 PR。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"base_url": "https://famzheng.me/gitea",
|
||||||
|
"username": "mochi",
|
||||||
|
"token_file": Path.home() / ".gitea-mochi-token",
|
||||||
|
"state_dir": Path.home() / ".local/state/gitea-bot",
|
||||||
|
"poll_interval_sec": 60,
|
||||||
|
"claude_bin": "/home/fam/.local/bin/claude",
|
||||||
|
"claude_timeout_sec": 1200,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
ts = datetime.now().isoformat(timespec="seconds")
|
||||||
|
print(f"[{ts}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
return CONFIG["token_file"].read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_get(path: str, token: str, **params) -> object:
|
||||||
|
url = f"{CONFIG['base_url']}/api/v1{path}"
|
||||||
|
if params:
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "gitea-bot/mochi",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_repos(token: str) -> list[dict]:
|
||||||
|
repos: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
batch = gitea_get("/repos/search", token, limit=50, page=page)
|
||||||
|
items = batch.get("data", []) if isinstance(batch, dict) else []
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
repos.extend(items)
|
||||||
|
if len(items) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
def list_open_issues(owner: str, repo: str, token: str, type_: str) -> list[dict]:
|
||||||
|
"""type_ in {'issues', 'pulls'}"""
|
||||||
|
items: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
batch = gitea_get(
|
||||||
|
f"/repos/{owner}/{repo}/issues",
|
||||||
|
token,
|
||||||
|
state="open",
|
||||||
|
type=type_,
|
||||||
|
page=page,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
if not isinstance(batch, list) or not batch:
|
||||||
|
break
|
||||||
|
items.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def list_comments(owner: str, repo: str, number: int, token: str) -> list[dict]:
|
||||||
|
out = gitea_get(f"/repos/{owner}/{repo}/issues/{number}/comments", token)
|
||||||
|
return out if isinstance(out, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def init_state(db_path: Path) -> sqlite3.Connection:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS handled (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
handled_at TEXT NOT NULL,
|
||||||
|
last_signal TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_handled_signal(conn: sqlite3.Connection, key: str) -> str | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT last_signal FROM handled WHERE key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_handled(conn: sqlite3.Connection, key: str, signal: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO handled(key, handled_at, last_signal) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
handled_at = excluded.handled_at,
|
||||||
|
last_signal = excluded.last_signal
|
||||||
|
""",
|
||||||
|
(key, datetime.now(timezone.utc).isoformat(), signal),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def latest_signal(issue: dict, comments: list[dict]) -> tuple[str, str]:
|
||||||
|
"""Return (time, author_login) for the latest activity on this issue."""
|
||||||
|
cands: list[tuple[str, str]] = [
|
||||||
|
(issue["updated_at"], issue["user"]["login"]),
|
||||||
|
]
|
||||||
|
for c in comments:
|
||||||
|
cands.append((c["updated_at"], c["user"]["login"]))
|
||||||
|
cands.sort(key=lambda x: x[0])
|
||||||
|
return cands[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(repo: dict, issue: dict, is_pr: bool, comments: list[dict]) -> str:
|
||||||
|
typ = "pull request" if is_pr else "issue"
|
||||||
|
full = repo["full_name"]
|
||||||
|
labels = ", ".join(l["name"] for l in issue.get("labels") or []) or "(none)"
|
||||||
|
body = (issue.get("body") or "(empty)").strip()
|
||||||
|
if len(body) > 8000:
|
||||||
|
body = body[:8000] + "\n…(truncated)"
|
||||||
|
|
||||||
|
comments_tail = comments[-10:]
|
||||||
|
if comments_tail:
|
||||||
|
comments_str = "\n\n".join(
|
||||||
|
f"### @{c['user']['login']} at {c['created_at']}\n{(c.get('body') or '').strip()}"
|
||||||
|
for c in comments_tail
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
comments_str = "(no comments yet)"
|
||||||
|
|
||||||
|
pr_note = ""
|
||||||
|
if is_pr:
|
||||||
|
pr_note = (
|
||||||
|
f"\nThis is a PULL REQUEST. You can fetch the diff at "
|
||||||
|
f"`GET /repos/{full}/pulls/{issue['number']}.diff` (raw text). "
|
||||||
|
f"You may post review comments via the issues comments endpoint, "
|
||||||
|
f"or full reviews via `POST /repos/{full}/pulls/{issue['number']}/reviews`.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""You are **mochi** (麻薯), the autonomous Gitea bot running site-wide on
|
||||||
|
{CONFIG['base_url']}. You are invoked once per "new activity" on an issue or PR
|
||||||
|
and must finish your work in this single run.
|
||||||
|
|
||||||
|
# Identity & access
|
||||||
|
|
||||||
|
- Gitea username: `mochi` (you have admin on this instance).
|
||||||
|
- Auth token: env var `MOCHI_TOKEN`. Send it as `Authorization: token $MOCHI_TOKEN`.
|
||||||
|
- API base: `{CONFIG['base_url']}/api/v1`
|
||||||
|
- SSH is disabled — git over HTTPS only. To clone/push:
|
||||||
|
`git clone https://mochi:$MOCHI_TOKEN@famzheng.me/gitea/<owner>/<repo>.git`
|
||||||
|
- Configure git author when committing:
|
||||||
|
`git -c user.name=mochi -c user.email=mochi@famzheng.me commit ...`
|
||||||
|
- Work in `/tmp/gitea-bot-<rand>/`. Clean up when done.
|
||||||
|
- You have full shell access (--dangerously-skip-permissions). Be careful but decisive.
|
||||||
|
|
||||||
|
# Voice
|
||||||
|
|
||||||
|
- Smart, concise senior-engineer tone. No customer-service fluff.
|
||||||
|
- Reply in the **same language** as the issue body (中文 or English — detect it).
|
||||||
|
- Do NOT use bracketed RP actions like `(笑)` `(歪头)` `(撒娇)`. Express tone with words.
|
||||||
|
- Sign every comment you POST with this footer line on its own line:
|
||||||
|
`🤖 mochi bot · automated · {CONFIG['base_url']}`
|
||||||
|
|
||||||
|
# Target {typ}
|
||||||
|
|
||||||
|
- Repo: `{full}` (default branch: `{repo.get('default_branch', 'main')}`)
|
||||||
|
- Issue/PR #: {issue['number']}
|
||||||
|
- Title: {issue['title']}
|
||||||
|
- Author: @{issue['user']['login']}
|
||||||
|
- URL: {issue['html_url']}
|
||||||
|
- Labels: {labels}
|
||||||
|
- State: {issue['state']}
|
||||||
|
- Created: {issue['created_at']} · Updated: {issue['updated_at']}
|
||||||
|
|
||||||
|
## Body
|
||||||
|
|
||||||
|
{body}
|
||||||
|
|
||||||
|
## Recent comments (oldest → newest, up to 10)
|
||||||
|
|
||||||
|
{comments_str}
|
||||||
|
{pr_note}
|
||||||
|
# What to do
|
||||||
|
|
||||||
|
1. Re-read the issue/PR. If the **latest** comment is from `mochi` and there's no
|
||||||
|
new question/instruction after it, exit silently — do not double-post.
|
||||||
|
2. Classify and act:
|
||||||
|
|
||||||
|
- **Question / discussion / unclear ask** → post **one** helpful comment
|
||||||
|
(≤ 8 lines). Ask clarifying questions if needed. Do not open a PR.
|
||||||
|
|
||||||
|
- **Feature request** (clear, scoped, small enough that you can implement it
|
||||||
|
in one PR):
|
||||||
|
a. `git clone` the repo to a temp dir.
|
||||||
|
b. `git switch -c mochi/issue-{issue['number']}`
|
||||||
|
c. Implement the change. If the repo has obvious build/test commands
|
||||||
|
(Makefile, package.json scripts, cargo, etc.), run them and ensure they pass.
|
||||||
|
d. Commit with a clear message; author `mochi <mochi@famzheng.me>`.
|
||||||
|
e. Push the branch.
|
||||||
|
f. Open a PR via `POST /repos/{full}/pulls` with base = default branch,
|
||||||
|
head = `mochi/issue-{issue['number']}`, title like
|
||||||
|
`feat: <summary> (closes #{issue['number']})`, body referencing the issue.
|
||||||
|
g. Post **one** short comment on the issue linking the PR.
|
||||||
|
|
||||||
|
If the request is too large, ambiguous, or risky → instead post a comment
|
||||||
|
with a proposed plan and ask for confirmation. Don't push.
|
||||||
|
|
||||||
|
- **Bug report** → if the fix is obvious from reading the code, follow the
|
||||||
|
same flow as feature request with title `fix: <summary> (closes #{issue['number']})`.
|
||||||
|
Otherwise post a diagnostic comment asking for repro steps / logs.
|
||||||
|
|
||||||
|
- **Pull request** → if the PR author is `mochi`, exit. Otherwise fetch the
|
||||||
|
diff and post **one** brief review comment (correctness/style/obvious bugs).
|
||||||
|
Don't push commits to other people's PRs.
|
||||||
|
|
||||||
|
3. Be conservative. When unsure, comment instead of pushing code.
|
||||||
|
|
||||||
|
Begin now. When done, exit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
repo: dict, issue: dict, is_pr: bool, comments: list[dict], token: str
|
||||||
|
) -> None:
|
||||||
|
prompt = build_prompt(repo, issue, is_pr, comments)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["MOCHI_TOKEN"] = token
|
||||||
|
# ensure claude can find git, curl, node (claude wraps node), etc.
|
||||||
|
env["PATH"] = (
|
||||||
|
"/home/fam/.local/bin:/home/fam/.cargo/bin:"
|
||||||
|
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
)
|
||||||
|
env["HOME"] = "/home/fam"
|
||||||
|
|
||||||
|
full = repo["full_name"]
|
||||||
|
log(
|
||||||
|
f"dispatch {full}#{issue['number']} "
|
||||||
|
f"({'PR' if is_pr else 'issue'}): {issue['title']!r}"
|
||||||
|
)
|
||||||
|
cmd = [CONFIG["claude_bin"], "--dangerously-skip-permissions", "-p", prompt]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=CONFIG["claude_timeout_sec"],
|
||||||
|
cwd="/tmp",
|
||||||
|
)
|
||||||
|
log(f" → exit={r.returncode}, stdout={len(r.stdout)}B, stderr={len(r.stderr)}B")
|
||||||
|
if r.stdout.strip():
|
||||||
|
tail = r.stdout[-1500:]
|
||||||
|
log(f" stdout tail:\n{tail}")
|
||||||
|
if r.stderr.strip():
|
||||||
|
tail = r.stderr[-1500:]
|
||||||
|
log(f" stderr tail:\n{tail}")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log(f" → TIMEOUT after {CONFIG['claude_timeout_sec']}s")
|
||||||
|
except Exception as e:
|
||||||
|
log(f" → dispatch error: {e!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def poll_once(conn: sqlite3.Connection, token: str) -> None:
|
||||||
|
repos = list_all_repos(token)
|
||||||
|
log(f"polling {len(repos)} repos")
|
||||||
|
for repo in repos:
|
||||||
|
if repo.get("archived"):
|
||||||
|
continue
|
||||||
|
owner = repo["owner"]["username"] if "username" in repo["owner"] else repo["owner"]["login"]
|
||||||
|
name = repo["name"]
|
||||||
|
full = repo["full_name"]
|
||||||
|
for type_ in ("issues", "pulls"):
|
||||||
|
try:
|
||||||
|
items = list_open_issues(owner, name, token, type_)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code in (403, 404):
|
||||||
|
continue
|
||||||
|
log(f" ! list {full} {type_}: HTTP {e.code}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
log(f" ! list {full} {type_}: {e!r}")
|
||||||
|
continue
|
||||||
|
for issue in items:
|
||||||
|
is_pr = "pull_request" in issue and issue["pull_request"] is not None
|
||||||
|
if type_ == "issues" and is_pr:
|
||||||
|
continue
|
||||||
|
if type_ == "pulls" and not is_pr:
|
||||||
|
continue
|
||||||
|
num = issue["number"]
|
||||||
|
try:
|
||||||
|
comments = list_comments(owner, name, num, token)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" ! comments {full}#{num}: {e!r}")
|
||||||
|
comments = []
|
||||||
|
latest_time, latest_user = latest_signal(issue, comments)
|
||||||
|
key = f"{full}#{num}"
|
||||||
|
if latest_user == CONFIG["username"]:
|
||||||
|
# last activity is mochi's own — record & skip
|
||||||
|
set_handled(conn, key, latest_time)
|
||||||
|
continue
|
||||||
|
if get_handled_signal(conn, key) == latest_time:
|
||||||
|
continue
|
||||||
|
dispatch(repo, issue, is_pr, comments, token)
|
||||||
|
set_handled(conn, key, latest_time)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
state_dir = CONFIG["state_dir"]
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = init_state(state_dir / "state.db")
|
||||||
|
log(
|
||||||
|
f"gitea-bot up; base={CONFIG['base_url']} "
|
||||||
|
f"interval={CONFIG['poll_interval_sec']}s "
|
||||||
|
f"claude={CONFIG['claude_bin']}"
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
token = load_token()
|
||||||
|
poll_once(conn, token)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"poll error: {e!r}")
|
||||||
|
time.sleep(CONFIG["poll_interval_sec"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=gitea-bot — mochi 全站 issue/PR 自动处理
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 /home/fam/src/gitea-bot/bot.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=15
|
||||||
|
# claude / git / curl / node 等都在 PATH 里
|
||||||
|
Environment=PATH=/home/fam/.local/bin:/home/fam/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
Environment=HOME=/home/fam
|
||||||
|
StandardOutput=append:/home/fam/.local/state/gitea-bot/bot.log
|
||||||
|
StandardError=append:/home/fam/.local/state/gitea-bot/bot.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in New Issue
Block a user