Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
name: Create Release PR

on:
push:
branches: [dev]
workflow_dispatch:

concurrency:
group: release-pr
cancel-in-progress: false

permissions:
contents: read
pull-requests: write

jobs:
create-pr:
environment:
name: release-pr
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -24,6 +32,10 @@ jobs:
pr_body: |
Automated PR to merge `dev` into `master`.

This workflow runs on every push to `dev`, but job execution can
be gated by required reviewers on the `release-pr` environment.
That keeps PR creation automatic while still requiring approval.

---
_Created via Release PR workflow._
pr_label: "release"
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@ Thumbs.db
.pytest_cache/
.coverage
htmlcov/
tests/

# mypy
.mypy_cache/

clean_*

target/
target/
184 changes: 184 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Contributing

Zero Ichi moves fast. Good contributions keep it stable while making it easier
to operate, extend, and debug.

This guide is for people sending code, docs, or workflow changes.

## Before You Start

- Open an issue first for bigger changes, behavior changes, or new features.
- Keep one pull request focused on one problem.
- If a change affects commands, config, or user-facing text, update docs and
locales in same PR.

## Local Setup

```bash
git clone https://github.com/MhankBarBar/zero-ichi
cd zero-ichi
uv sync
cp .env.example .env
```

Run bot:

```bash
uv run zero-ichi
```

Interactive first-run setup:

```bash
uv run zero-ichi setup
```

Docs dev server:

```bash
cd docs
bun install
bun run docs:dev
```

## Branching

- Branch from `dev`.
- Use short branch names that say what changed.
- Examples:
- `fix/addskill-bom`
- `feat/privacy-controls`
- `docs/mobile-table-overflow`

## Project Rules

### Commands

- Add commands under `src/commands/<category>/`.
- Follow `Command` base class pattern already used in repo.
- Respect existing permission flags: `owner_only`, `admin_only`,
`group_only`, `private_only`.
- Reuse shared helpers before adding new mini-frameworks.

### Config Changes

If you add or change runtime config:

1. Update `DEFAULT_CONFIG` in `src/core/runtime_config.py`
2. Update `config.schema.json`
3. Preserve merge/backfill behavior for existing user config
4. Add or update command/docs examples if users need to touch it

### User-Facing Text

If command output changes:

1. Add/update `src/locales/en.json`
2. Add/update `src/locales/id.json`
3. Keep keys parallel across both files

### Docs

Update docs when you change:

- command behavior
- config shape
- workflow/release process
- setup flow
- moderation behavior

Main docs live in `docs/`.

## Validation

Minimum before opening PR:

```bash
uv run ruff format .
uv run ruff check .
```

Run tests relevant to your change.

If you are preparing a merge-ready branch, run full suite too:

```bash
uv run pytest -q
```

Docs changes should also build:

```bash
cd docs
bun run docs:build
```

## Tests Policy

Repo currently ignores `tests/` in Git.

- Keep exploratory tests local.
- If you need permanent test coverage in-repo, discuss it in issue/PR first so
policy stays deliberate instead of accidental.

## Pull Request Expectations

Good PRs include:

- clear title
- short summary of problem
- short summary of fix
- validation steps you ran
- screenshots or terminal output when UI/docs behavior changed

Bad PRs usually have one of these problems:

- mix refactor and feature work with no separation
- update code but not docs/locales/schema
- add duplicate helper instead of extending shared one
- change behavior without explaining why

## Commit Messages

Use Conventional Commits.

Examples:

```text
fix(ai): handle BOM in skill markdown
feat(config): add rollback history
docs(commands): clarify addskill usage
refactor(core): share prefixed id generator
```

## Release PR Automation

Repo has release PR workflow for `dev -> master`.

- Push to `dev` triggers release PR workflow automatically.
- Job uses `release-pr` environment.
- To require approval before PR creation, configure required reviewers in
GitHub Settings -> Environments -> `release-pr`.

That gives automatic trigger with explicit approval gate.

## What Usually Needs Extra Care

- command permission logic
- runtime config persistence and schema validation
- WhatsApp message parsing, especially quoted/media messages
- AI flows that edit messages after async work
- docs tables/layout on mobile
- GitHub workflows that can create PRs, push branches, or deploy pages

## Reporting Problems

Open issue with:

- exact command or workflow involved
- steps to reproduce
- expected result
- actual result
- logs, screenshots, or copied error text

Short, concrete reports get fixed faster.
28 changes: 28 additions & 0 deletions docs/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,34 @@ select:focus {
/* ─── Responsive ───────────────────────────────────────────────── */

@media (max-width: 960px) {
html,
body,
.Layout,
.VPDoc {
overflow-x: hidden;
}

.vp-doc table {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
table-layout: auto;
}

.vp-doc table thead,
.vp-doc table tbody {
display: table;
width: max-content;
min-width: 100%;
}

.vp-doc th,
.vp-doc td {
white-space: nowrap;
}

.stats-section {
grid-template-columns: 1fr;
}
Expand Down
20 changes: 14 additions & 6 deletions src/commands/general/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import time

from sqlalchemy import text
Expand All @@ -22,19 +23,26 @@ class StatusCommand(Command):
usage = "status"
category = "general"

async def execute(self, ctx: CommandContext) -> None:
from commands.general.uptime import _start_time
def _ping_db(self) -> bool:
"""Run blocking DB ping outside event loop."""
from core.db import get_engine

uptime = format_uptime(time.time() - _start_time)

db_ok = False
try:
engine = get_engine()
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_ok = True
return True
except Exception:
return False

async def execute(self, ctx: CommandContext) -> None:
from commands.general.uptime import _start_time

uptime = format_uptime(time.time() - _start_time)

try:
db_ok = await asyncio.wait_for(asyncio.to_thread(self._ping_db), timeout=2.0)
except TimeoutError:
db_ok = False

webhook = webhook_dispatcher_status()
Expand Down
10 changes: 9 additions & 1 deletion src/commands/owner/addskill.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ def build_inline_skill(raw_args: str, quoted_text: str = "") -> dict[str, Any] |
}


def decode_skill_document(media_data: bytes) -> str:
"""Decode markdown skill document bytes.

`utf-8-sig` handles both plain UTF-8 and UTF-8 BOM files.
"""
return media_data.decode("utf-8-sig")


class AddSkillCommand(Command):
name = "addskill"
aliases = ["skill"]
Expand Down Expand Up @@ -92,7 +100,7 @@ async def execute(self, ctx: CommandContext) -> None:
try:
media_data = await ctx.client._client.download_any(msg_obj)
if media_data:
content = media_data.decode("utf-8")
content = decode_skill_document(media_data)
skill = parse_skill_markdown(content)
if skill:
save_skill_to_file(skill)
Expand Down
9 changes: 7 additions & 2 deletions src/commands/utility/_ai_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ def ensure_provider_key(provider: str, api_key: str) -> None:
apply_provider_env(provider, api_key)


async def ensure_ai_ready_or_reply(ctx: CommandContext, disabled_key: str) -> bool:
async def ensure_ai_ready_or_reply(
ctx: CommandContext,
disabled_key: str,
*,
no_key_key: str = "summarize.no_api_key",
) -> bool:
"""Validate AI enabled + API key; reply with localized error when invalid."""
ai_enabled = runtime_config.get_nested("agentic_ai", "enabled", default=False)
if not ai_enabled:
Expand All @@ -40,7 +45,7 @@ async def ensure_ai_ready_or_reply(ctx: CommandContext, disabled_key: str) -> bo

api_key = get_api_key()
if not api_key:
await ctx.client.reply(ctx.message, t_error("summarize.no_api_key"))
await ctx.client.reply(ctx.message, t_error(no_key_key))
return False
return True

Expand Down
6 changes: 5 additions & 1 deletion src/commands/utility/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ class RewriteCommand(Command):
cooldown = 10

async def execute(self, ctx: CommandContext) -> None:
if not await ensure_ai_ready_or_reply(ctx, "rewrite.ai_disabled"):
if not await ensure_ai_ready_or_reply(
ctx,
"rewrite.ai_disabled",
no_key_key="rewrite.no_api_key",
):
return

if not ctx.args:
Expand Down
6 changes: 5 additions & 1 deletion src/commands/utility/summarize.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ class SummarizeCommand(Command):

async def execute(self, ctx: CommandContext) -> None:
"""Summarize quoted text or recent chat memory."""
if not await ensure_ai_ready_or_reply(ctx, "summarize.ai_disabled"):
if not await ensure_ai_ready_or_reply(
ctx,
"summarize.ai_disabled",
no_key_key="summarize.no_api_key",
):
return

text_to_summarize = ""
Expand Down
6 changes: 5 additions & 1 deletion src/commands/utility/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ class TranslateCommand(Command):
cooldown = 10

async def execute(self, ctx: CommandContext) -> None:
if not await ensure_ai_ready_or_reply(ctx, "translate.ai_disabled"):
if not await ensure_ai_ready_or_reply(
ctx,
"translate.ai_disabled",
no_key_key="translate.no_api_key",
):
return

if not ctx.args:
Expand Down
Loading
Loading