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
34 changes: 33 additions & 1 deletion src/aqua/cli/qr.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import click

from ..tools import qr_decode
from ..tools import qr_decode, qr_generate
from .output import run_tool


Expand All @@ -9,6 +9,38 @@ def qr():
"""QR code utilities."""


@qr.command("generate")
@click.argument("data")
@click.option(
"--output-dir",
type=click.Path(file_okay=False, dir_okay=True, path_type=str),
default=None,
help="Directory where the PNG should be written (default: ~/.aqua/qr).",
)
@click.option(
"--filename",
default=None,
help="Optional PNG filename. Defaults to qr_<sha256[:16]>.png.",
)
@click.option(
"--terminal",
is_flag=True,
help="Also include a terminal-friendly block QR in the output.",
)
@click.pass_obj
def generate(ctx, data, output_dir, filename, terminal):
"""Generate a QR code PNG for DATA."""
run_tool(
ctx,
lambda: qr_generate(
data=data,
output_dir=output_dir,
filename=filename,
terminal=terminal,
),
)


@qr.command("decode")
@click.argument("image_path")
@click.pass_obj
Expand Down
1 change: 1 addition & 0 deletions src/aqua/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
("wapupay", "provision-account"): "wapupay_provision_account",

# qr group (cli/qr.py)
("qr", "generate"): "qr_generate",
("qr", "decode"): "qr_decode",

# jan3 group (cli/jan3.py) — JAN3 account login + sessions + Lightning Address
Expand Down
19 changes: 19 additions & 0 deletions src/aqua/qr.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ def generate_qr(data: str, output_dir: str | Path, filename: str | None = None)
return str(path.resolve())


def render_qr_terminal(data: str) -> str:
"""Render ``data`` as a terminal-friendly block QR string.

This is intentionally text-only (no ANSI colours) so it works in logs,
JSON output, and terminals with minimal capabilities. Each QR module is
represented by two spaces so the square proportions remain scannable.
"""
if not isinstance(data, str) or not data:
raise ValueError("QR data must be a non-empty string")

qr = qrcode.QRCode(border=1)
qr.add_data(data)
qr.make(fit=True)
lines = []
for row in qr.get_matrix():
lines.append("".join("██" if cell else " " for cell in row))
return "\n".join(lines)


def decode_qr(image_path: str) -> str:
path = Path(image_path)
if not path.is_file():
Expand Down
29 changes: 29 additions & 0 deletions src/aqua/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,35 @@
"required": ["email"],
},
},
"qr_generate": {
"description": (
"Generate a PNG QR code for arbitrary content and return qr_code_path. "
"Optionally includes a terminal-friendly block QR string."
),
"inputSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"description": "Content to encode (address, invoice, URI, or other QR payload).",
},
"output_dir": {
"type": "string",
"description": "Optional directory for the PNG; defaults to ~/.aqua/qr.",
},
"filename": {
"type": "string",
"description": "Optional PNG filename; defaults to qr_<sha256[:16]>.png.",
},
"terminal": {
"type": "boolean",
"default": False,
"description": "Also return terminal_qr block art.",
},
},
"required": ["data"],
},
},
"qr_decode": {
"description": "Decode a QR code from an image file and return the raw string content. Supports Bitcoin addresses, Liquid addresses, Lightning invoices (BOLT11), and Lightning addresses.",
"inputSchema": {
Expand Down
18 changes: 17 additions & 1 deletion src/aqua/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from .assets import MAINNET_ASSETS, TESTNET_ASSETS, resolve_asset_name, resolve_liquid_asset_id
from .bitcoin import BitcoinWalletManager
from .bolt11 import decode_bolt11_fields
from .qr import decode_qr, generate_qr
from .qr import decode_qr, generate_qr, render_qr_terminal
from .wallet import WalletManager

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1980,6 +1980,21 @@ def sideswap_swap_status(order_id: str) -> dict[str, Any]:
return manager.status(order_id)


def qr_generate(
data: str,
output_dir: str | None = None,
filename: str | None = None,
terminal: bool = False,
) -> dict[str, Any]:
"""Generate a QR PNG for arbitrary content, optionally with terminal art."""
qr_dir = output_dir or str(get_manager().storage.qr_dir)
Comment thread
marinate305 marked this conversation as resolved.
path = generate_qr(data, qr_dir, filename=filename)
Comment on lines +1990 to +1991

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function does not have any type of restriction on data's length. It may generate data overflow errors or similars.

result: dict[str, Any] = {"content": data, "qr_code_path": path}
if terminal:
result["terminal_qr"] = render_qr_terminal(data)
return result


def qr_decode(image_path: str) -> dict[str, Any]:
"""Decode a QR code from an image file and return the raw string content."""
content = decode_qr(image_path)
Expand Down Expand Up @@ -2301,5 +2316,6 @@ def jan3_purchase_ln_username(
"jan3_rebind_wallet": jan3_rebind_wallet,
"jan3_ln_check_username": jan3_ln_check_username,
"jan3_purchase_ln_username": jan3_purchase_ln_username,
"qr_generate": qr_generate,
"qr_decode": qr_decode,
}
33 changes: 33 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,39 @@ def test_serve_help_has_no_transport_option(self, runner):
assert "Start the MCP server over stdio (`aqua serve` or `aqua-mcp`)." in result.output


# QR commands


class TestQrCommands:
def test_qr_generate_json_creates_png_that_decodes(self, runner):
from aqua.qr import decode_qr

payload = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"

result = runner.invoke(cli, ["--format", "json", "qr", "generate", payload])

assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["content"] == payload
path = Path(data["qr_code_path"])
assert path.is_file()
assert decode_qr(str(path)) == payload

def test_qr_generate_can_include_terminal_qr(self, runner):
payload = "liquid-address-example"

result = runner.invoke(
cli, ["--format", "json", "qr", "generate", "--terminal", payload]
)

assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["content"] == payload
assert "terminal_qr" in data
assert "██" in data["terminal_qr"]
assert len(data["terminal_qr"].splitlines()) > 5


# Wallet commands


Expand Down
1 change: 1 addition & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,7 @@ def test_all_tools_registered(self):
"lightning_send",
"lightning_transaction_status",
"lightning_decode",
"qr_generate",
"qr_decode",
"delete_wallet",
"btc_import_descriptor",
Expand Down
Loading