This commit is contained in:
Arc
2026-02-25 16:20:26 +00:00
parent 3fd7aae10d
commit 4e8a5e050b
10 changed files with 175 additions and 76 deletions
+8
View File
@@ -10,11 +10,13 @@ nav_order: 4
This guide is for AI agents or developers using AI to build **traditional (Python) LNbits extensions**. It defines what to change, what not to change, and the expected structure.
## Hard Rules (Non-Negotiable)
- Do **not** change core LNbits files.
- Only edit files inside your extension folder.
- Do **not** add new Python dependencies unless explicitly approved.
## Extension Folder Layout (Python)
Your extension lives under:
```
@@ -22,6 +24,7 @@ lnbits/extensions/<ext_id>/
```
Typical files to edit:
- `views.py` (HTML routes)
- `views_api.py` (API routes)
- `crud.py` / `models.py` (storage logic + models)
@@ -31,18 +34,22 @@ Typical files to edit:
- `config.json`, `manifest.json`, `README.md`
## What You Can Do
Python extensions can:
- Define their own database schema via `migrations.py`
- Run long-running background tasks via `*_start()` and `*_stop()` hooks
- Access LNbits internal services directly in Python
- Expose custom API routes under `/<ext_id>/api/v1/...`
## What You Must Not Do
- Do not modify core services or routes.
- Do not patch LNbits internals for your extension.
- Avoid direct DB access outside your own schema.
## Background Tasks
Implement background tasks by exposing:
```
@@ -53,6 +60,7 @@ def <ext_id>_stop():
Use `register_invoice_listener` or `wait_for_paid_invoices` if you need to react to payments.
## Testing Checklist
- Extension loads without errors.
- Migrations apply cleanly.
- Routes are registered under `/<ext_id>/...`.
+41 -4
View File
@@ -10,11 +10,13 @@ nav_order: 3
This guide is written for AI agents or developers using AI to build LNbits WASM extensions. It describes what to change, what not to change, and the available capabilities/limits.
## Hard Rules (Non-Negotiable)
- Do **not** change core LNbits files. Only edit files inside your extension folder.
- Do **not** add new Python dependencies.
- Do **not** rely on long-running WASM processes. WASM runs per-call with timeouts.
## Extension Folder Layout (WASM)
Your extension lives under:
```
@@ -22,6 +24,7 @@ lnbits/extensions/<ext_id>/
```
You should only edit files under this folder, typically:
- `config.json` (metadata, permissions, tags, public handlers)
- `wasm/` (your `module.wasm` or `module.wat`)
- `static/` (frontend assets)
@@ -29,7 +32,9 @@ You should only edit files under this folder, typically:
- `manifest.json`, `README.md`, `description.md` (docs and metadata)
## Required Config Fields
In `config.json`:
- `id` / `name`
- `extension_type: "wasm"`
- `permissions` (required API permissions)
@@ -38,6 +43,7 @@ In `config.json`:
- `payment_tags` (list of tags the user may grant for watcher access)
Example:
```json
{
"id": "myext",
@@ -45,19 +51,37 @@ Example:
"extension_type": "wasm",
"permissions": [
{"id": "ext.db.read_write", "label": "DB access", "description": "..."},
{"id": "api.POST:/api/v1/payments", "label": "Create invoices", "description": "..."},
{"id": "ext.payments.watch", "label": "Watch payments", "description": "..."},
{"id": "ext.tasks.schedule", "label": "Schedule tasks", "description": "..."},
{
"id": "api.POST:/api/v1/payments",
"label": "Create invoices",
"description": "..."
},
{
"id": "ext.payments.watch",
"label": "Watch payments",
"description": "..."
},
{
"id": "ext.tasks.schedule",
"label": "Schedule tasks",
"description": "..."
},
{"id": "ext.db.sql", "label": "SQL access", "description": "..."}
],
"public_wasm_functions": ["public_create_invoice", "on_tag_payment", "on_schedule"],
"public_wasm_functions": [
"public_create_invoice",
"on_tag_payment",
"on_schedule"
],
"public_kv_keys": ["public_lists", "public_tasks"],
"payment_tags": ["coinflip", "myext"]
}
```
## What the WASM Host Can Do
WASM runs in a short-lived subprocess. It can:
- Read/write extension KV (`/api/v1/kv/*`)
- Read/write secret KV (`/api/v1/secret/*`)
- Call internal LNbits endpoints (only if declared + granted)
@@ -65,7 +89,9 @@ WASM runs in a short-lived subprocess. It can:
- Run backend tag watchers and scheduled handlers (server-side triggers)
## Permissions Model
Your extension can only call or access what is declared and granted:
- `api.METHOD:/path` for internal endpoints (core or other extensions)
- `ext.db.read_write` for KV access
- `ext.payments.watch` for payment watchers
@@ -75,6 +101,7 @@ Your extension can only call or access what is declared and granted:
If the endpoint doesnt exist, permissions wont save.
## Tag Watchers (Backend)
You can register tag watchers:
```
@@ -88,10 +115,12 @@ POST /<ext_id>/api/v1/watch_tag
```
Constraints:
- Tag must be in `payment_tags` and granted by the user.
- Watchers are persisted and restored on restart.
## Scheduled Tasks (Backend)
You can schedule periodic handlers:
```
@@ -104,32 +133,40 @@ POST /<ext_id>/api/v1/schedule
```
Constraints:
- Requires `ext.tasks.schedule` permission.
- Minimum interval is 5 seconds.
- Stored in extension KV and restored on restart.
## SQL Interface (Limited)
You can run SQL within your extension schema:
- `/api/v1/sql/query` (SELECT only)
- `/api/v1/sql/exec` (limited DDL/DML)
Rules:
- Single statement only
- No `PRAGMA`, no `sqlite_master`
- No cross-schema access
## Public Pages (No Keys)
Public pages must not depend on `window.g` or wallet keys.
They can call:
- `/{ext_id}/api/v1/public/kv/{key}`
- `/{ext_id}/api/v1/public/call/{handler}`
## What Not To Do
- Do not write to core routes or override existing LNbits paths.
- Do not add background threads; use watchers or scheduler instead.
- Do not assume the WASM process persists.
## Testing Checklist
- Permissions show correctly in the extensions UI.
- Public handlers are in `public_wasm_functions`.
- Public KV keys are explicitly listed.
+10 -3
View File
@@ -36,9 +36,11 @@ from lnbits.core.tasks import (
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.core.tasks import (
wait_for_paid_invoices as wait_for_paid_invoices_core,
)
from lnbits.core.wasm.extension_host import (
handle_wasm_tag_payment,
register_wasm_ext_routes,
@@ -52,6 +54,9 @@ from lnbits.tasks import (
create_permanent_task,
register_invoice_listener,
)
from lnbits.tasks import (
wait_for_paid_invoices as wait_for_paid_invoices_listener,
)
from lnbits.utils.cache import cache
from lnbits.utils.logger import (
configure_logger,
@@ -504,10 +509,12 @@ def register_async_tasks() -> None:
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(lambda: wait_for_paid_invoices_core(invoice_queue))
# wasm tag watcher listener
create_permanent_task(wait_for_paid_invoices("wasm_tags", handle_wasm_tag_payment))
create_permanent_task(
wait_for_paid_invoices_listener("wasm_tags", handle_wasm_tag_payment)
)
create_permanent_task(wasm_scheduler)
create_permanent_task(run_by_the_minute_tasks)
+3 -3
View File
@@ -195,9 +195,9 @@ class Extension(BaseModel):
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
extension_type=ext_info.meta.extension_type if ext_info.meta else None,
public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [],
public_wasm_functions=ext_info.meta.public_wasm_functions
if ext_info.meta
else [],
public_wasm_functions=(
ext_info.meta.public_wasm_functions if ext_info.meta else []
),
payment_tags=ext_info.meta.payment_tags if ext_info.meta else [],
)
+2 -2
View File
@@ -21,7 +21,7 @@ from lnbits.core.wasm.extension_host import (
clear_schedules_for_extension,
clear_tag_watches_for_extension,
)
from lnbits.db import Connection, Database, COCKROACH, POSTGRES
from lnbits.db import COCKROACH, POSTGRES, Connection, Database
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -93,7 +93,7 @@ async def _purge_wasm_extension_db(ext_id: str) -> None:
try:
db = Database(f"ext_{ext_id}")
if db.type in {POSTGRES, COCKROACH}:
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE") # noqa: S608
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE")
except Exception as exc:
logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}")
+4 -3
View File
@@ -8,8 +8,8 @@ import httpx
from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.requests import Request
from starlette.routing import Match
from loguru import logger
from starlette.routing import Match
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
@@ -35,7 +35,6 @@ from lnbits.core.models.extensions import (
UserExtension,
UserExtensionInfo,
)
from lnbits.core.wasm import WASM_HOST_MANIFEST
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import (
@@ -46,6 +45,7 @@ from lnbits.core.services.extensions import (
install_extension,
uninstall_extension,
)
from lnbits.core.wasm import WASM_HOST_MANIFEST
from lnbits.core.wasm.extension_host import (
clear_schedules_for_user,
clear_tag_watches_for_user,
@@ -976,7 +976,8 @@ def _route_exists(request: Request, method: str, path: str) -> bool:
continue
try:
match, _ = route.matches(scope)
except Exception:
except Exception as exc:
logger.debug(f"Route match failed for {method} {path}: {exc!s}")
continue
if match == Match.FULL:
return True
+3 -1
View File
@@ -15,7 +15,9 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
@websocket_router.websocket("/tag/{tag}")
async def websocket_connect_tag(websocket: WebSocket, tag: str) -> None:
api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get("api-key")
api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get(
"api-key"
)
if not api_key:
await websocket.close(code=4401)
return
+65 -37
View File
@@ -4,22 +4,22 @@ import asyncio
import json
import re
import time
from pathlib import Path
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
from loguru import logger
from lnbits.core.crud.extensions import get_user_extension
from lnbits.core.crud.payments import get_standalone_payment, update_payment
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import User
from lnbits.core.models.payments import Payment
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.crud.payments import get_standalone_payment, update_payment
from lnbits.core.services import websocket_updater
from loguru import logger
from lnbits.db import Database
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
@@ -42,6 +42,7 @@ class TagWatch:
_tag_watchers: dict[tuple[str, str], list[TagWatch]] = {}
_TAG_WATCH_KV_KEY = "watch_tags"
_background_tasks: list[asyncio.Task] = []
@dataclass
@@ -323,19 +324,23 @@ async def handle_wasm_tag_payment(payment: Payment) -> None:
if not watchers:
return
for watch in watchers:
asyncio.create_task(_dispatch_tag_watch(payment, watch))
_background_tasks.append(
asyncio.create_task(_dispatch_tag_watch(payment, watch))
)
async def _dispatch_tag_watch(payment: Payment, watch: TagWatch) -> None:
try:
user_ext = await get_user_extension(watch.user_id, watch.ext_id)
if not user_ext or not user_ext.active:
_remove_tag_watch(payment.wallet_id, payment.tag, watch)
if payment.tag:
_remove_tag_watch(payment.wallet_id, payment.tag, watch)
await _remove_persisted_tag_watch(watch.ext_id, watch)
return
granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else []
if watch.tag not in (granted_tags or []):
_remove_tag_watch(payment.wallet_id, payment.tag, watch)
if payment.tag:
_remove_tag_watch(payment.wallet_id, payment.tag, watch)
await _remove_persisted_tag_watch(watch.ext_id, watch)
return
@@ -409,7 +414,9 @@ async def clear_tag_watches_for_user(ext_id: str, user_id: str) -> None:
keys = list(_tag_watchers.keys())
for wallet_id, tag in keys:
existing = _tag_watchers.get((wallet_id, tag), [])
remaining = [w for w in existing if not (w.ext_id == ext_id and w.user_id == user_id)]
remaining = [
w for w in existing if not (w.ext_id == ext_id and w.user_id == user_id)
]
if remaining:
_tag_watchers[(wallet_id, tag)] = remaining
else:
@@ -488,11 +495,13 @@ async def restore_tag_watches(ext_id: str, upgrade_hash: str | None) -> None:
async def wasm_scheduler() -> None:
while settings.lnbits_running:
now = time.time()
for ext_id, tasks in list(_scheduled_tasks.items()):
for _ext_id, tasks in list(_scheduled_tasks.items()):
for task in list(tasks):
if task.next_run > now:
continue
asyncio.create_task(_dispatch_schedule_task(task))
_background_tasks.append(
asyncio.create_task(_dispatch_schedule_task(task))
)
task.next_run = now + max(1, task.interval_seconds)
await asyncio.sleep(1)
@@ -501,14 +510,18 @@ async def _dispatch_schedule_task(task: ScheduleTask) -> None:
try:
user_ext = await get_user_extension(task.user_id, task.ext_id)
if not user_ext or not user_ext.active:
_remove_schedule_entries(task.ext_id, task.user_id, task.handler, task.store_key)
_remove_schedule_entries(
task.ext_id, task.user_id, task.handler, task.store_key
)
await _remove_persisted_schedule_entries(
task.ext_id, task.user_id, task.handler, task.store_key
)
return
granted = user_ext.extra.granted_permissions if user_ext.extra else []
if "ext.tasks.schedule" not in (granted or []):
_remove_schedule_entries(task.ext_id, task.user_id, task.handler, task.store_key)
_remove_schedule_entries(
task.ext_id, task.user_id, task.handler, task.store_key
)
await _remove_persisted_schedule_entries(
task.ext_id, task.user_id, task.handler, task.store_key
)
@@ -839,9 +852,9 @@ def _register_public_call_routes(
) -> None:
@router.post("/api/v1/public/call/{handler}")
async def api_public_wasm_call(handler: str, payload: dict):
funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions(
ext_id
)
funcs = getattr(
ext, "public_wasm_functions", None
) or _load_public_wasm_functions(ext_id)
if handler not in funcs:
raise HTTPException(404, "Handler not public")
_check_quota("public", ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
@@ -936,7 +949,9 @@ def _register_kv_write_routes(router: APIRouter, ext_id: str, db: Database) -> N
return {"key": key, "value": value}
def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
def _register_watch_routes( # noqa: C901
router: APIRouter, ext_id: str, db: Database, ext
) -> None:
@router.post("/api/v1/watch")
async def api_watch_payment(payload: dict, user: User = Depends(check_user_exists)):
payment_hash = payload.get("payment_hash")
@@ -971,9 +986,9 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
if not wallet or wallet.user != user.id:
raise HTTPException(403, "Wallet not found or not owned by user")
funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions(
ext_id
)
funcs = getattr(
ext, "public_wasm_functions", None
) or _load_public_wasm_functions(ext_id)
if handler not in funcs:
raise HTTPException(400, "Handler not allowed")
@@ -1022,9 +1037,7 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != user.id:
raise HTTPException(403, "Wallet not found or not owned by user")
_remove_tag_watch_entries(
ext_id, user.id, wallet_id, tag, handler, store_key
)
_remove_tag_watch_entries(ext_id, user.id, wallet_id, tag, handler, store_key)
await _remove_persisted_tag_watch_entries(
ext_id, user.id, wallet_id, tag, handler, store_key
)
@@ -1040,16 +1053,16 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
try:
interval_seconds = int(interval_seconds)
except Exception:
raise HTTPException(400, "Invalid interval_seconds")
raise HTTPException(400, "Invalid interval_seconds") from None
if interval_seconds < 5:
raise HTTPException(400, "Minimum interval is 5 seconds")
await _require_permission(user.id, ext_id, "ext.tasks.schedule")
await _require_permission(user.id, ext_id, "ext.db.read_write")
funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions(
ext_id
)
funcs = getattr(
ext, "public_wasm_functions", None
) or _load_public_wasm_functions(ext_id)
if handler not in funcs:
raise HTTPException(400, "Handler not allowed")
@@ -1088,7 +1101,7 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
await _require_permission(user.id, ext_id, "ext.db.sql")
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
_validate_sql(ext_id, sql, read_only=True)
rows = await db.fetchall(sql, params) # noqa: S608
rows: list[dict] = await db.fetchall(sql, params)
return {"rows": rows}
@router.post("/api/v1/sql/exec")
@@ -1102,11 +1115,11 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
await _require_permission(user.id, ext_id, "ext.db.sql")
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
_validate_sql(ext_id, sql, read_only=False)
await db.execute(sql, params) # noqa: S608
await db.execute(sql, params)
return {"ok": True}
def _start_payment_watch(
def _start_payment_watch( # noqa: C901
ext_id: str,
db: Database,
payment_hash: str,
@@ -1118,20 +1131,24 @@ def _start_payment_watch(
queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}"
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, queue_name)
logger.debug(f"wasm watch registered ext={ext_id} hash={payment_hash} store_key={store_key}")
logger.debug(
f"wasm watch registered ext={ext_id} hash={payment_hash} store_key={store_key}"
)
async def _watch():
try:
existing = await get_standalone_payment(payment_hash, incoming=True)
if existing and existing.pending is False:
logger.debug(f"wasm watch already paid ext={ext_id} hash={payment_hash}")
logger.debug(
f"wasm watch already paid ext={ext_id} hash={payment_hash}"
)
if tag:
extra = existing.extra or {}
if extra.get("tag") != tag:
extra["tag"] = tag
existing.extra = extra
existing.tag = tag
await update_payment(existing, conn=db)
await update_payment(existing)
payload_json = json.dumps(existing.dict(exclude={"preimage"}))
await _kv_set(db, ext_id, store_key, payload_json)
await websocket_updater(f"{ext_id}:{store_key}", payload_json)
@@ -1154,14 +1171,19 @@ def _start_payment_watch(
payment = await invoice_queue.get()
if payment.payment_hash != payment_hash:
continue
logger.debug(f"wasm watch event ext={ext_id} hash={payment_hash} pending={payment.pending}")
logger.debug(
"wasm watch event ext=%s hash=%s pending=%s",
ext_id,
payment_hash,
payment.pending,
)
if tag:
extra = payment.extra or {}
if extra.get("tag") != tag:
extra["tag"] = tag
payment.extra = extra
payment.tag = tag
await update_payment(payment, conn=db)
await update_payment(payment)
if payment.pending is False:
payload_json = json.dumps(payment.dict(exclude={"preimage"}))
await _kv_set(db, ext_id, store_key, payload_json)
@@ -1171,7 +1193,9 @@ def _start_payment_watch(
"store_key": store_key,
"tag": tag,
}
await _kv_set(db, ext_id, "watch_request", json.dumps(watch_payload))
await _kv_set(
db, ext_id, "watch_request", json.dumps(watch_payload)
)
watch_value = store_key.rsplit(":", 1)[-1]
await _kv_set(db, ext_id, "public_request", watch_value)
await wasm_call(
@@ -1251,8 +1275,12 @@ def register_wasm_ext_routes(app, ext) -> None:
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
app.include_router(router, prefix=prefix)
asyncio.create_task(restore_tag_watches(ext_id, ext.upgrade_hash))
asyncio.create_task(restore_schedules(ext_id, ext.upgrade_hash))
_background_tasks.append(
asyncio.create_task(restore_tag_watches(ext_id, ext.upgrade_hash))
)
_background_tasks.append(
asyncio.create_task(restore_schedules(ext_id, ext.upgrade_hash))
)
_quota_events: dict[tuple[str, str, str], list[float]] = {}
+38 -21
View File
@@ -19,8 +19,8 @@ from wasmtime import (
ValType,
)
from lnbits.db import Database
from lnbits.core.services import websocket_updater
from lnbits.db import Database
from lnbits.settings import settings
_kv_schema_cache: dict[str, dict] = {}
@@ -169,7 +169,8 @@ def _get_memory(caller: Caller):
def _read_bytes(caller: Caller, ptr: int, length: int) -> bytes:
memory = _get_memory(caller)
return memory.read(caller, ptr, ptr + length)
# wasmtime's memory API is dynamically typed; keep pyright quiet
return memory.read(caller, ptr, ptr + length) # type: ignore[attr-defined]
def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None:
@@ -179,7 +180,7 @@ def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None:
elif isinstance(data, str):
data = data.encode()
try:
memory.write(caller, data, ptr)
memory.write(caller, data, ptr) # type: ignore[attr-defined]
except Exception as exc:
raise RuntimeError(f"memory.write failed for type={type(data)}") from exc
@@ -468,6 +469,35 @@ def _load_module(module_path: Path, ext_id: str):
access_caller=True,
),
)
def _http_request_wrapper(
caller,
method_ptr,
method_len,
path_ptr,
path_len,
body_ptr,
body_len,
key_ptr,
key_len,
out_ptr,
out_len,
):
return _http_request(
ext_id,
caller,
method_ptr,
method_len,
path_ptr,
path_len,
body_ptr,
body_len,
key_ptr,
key_len,
out_ptr,
out_len,
)
linker_define(
linker,
"host",
@@ -489,20 +519,7 @@ def _load_module(module_path: Path, ext_id: str):
],
[ValType.i32()],
),
lambda caller, method_ptr, method_len, path_ptr, path_len, body_ptr, body_len, key_ptr, key_len, out_ptr, out_len: _http_request(
ext_id,
caller,
method_ptr,
method_len,
path_ptr,
path_len,
body_ptr,
body_len,
key_ptr,
key_len,
out_ptr,
out_len,
),
_http_request_wrapper,
access_caller=True,
),
)
@@ -519,10 +536,10 @@ def _load_module(module_path: Path, ext_id: str):
lambda caller, topic_ptr, topic_len, payload_ptr, payload_len: _run(
_ws_publish(
ext_id,
_read_bytes(caller, topic_ptr, topic_len)
.decode(errors="ignore"),
_read_bytes(caller, payload_ptr, payload_len)
.decode(errors="ignore"),
_read_bytes(caller, topic_ptr, topic_len).decode(errors="ignore"),
_read_bytes(caller, payload_ptr, payload_len).decode(
errors="ignore"
),
)
),
access_caller=True,
+1 -2
View File
@@ -956,8 +956,7 @@
</q-list>
<div
v-if="
permissionsDialog.tagOptions &&
permissionsDialog.tagOptions.length
permissionsDialog.tagOptions && permissionsDialog.tagOptions.length
"
class="q-mt-md"
>