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