Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c120953b02 | ||
|
|
5eb9f435ec | ||
|
|
e79e9fc149 | ||
|
|
60782d6276 | ||
|
|
421248b0ad | ||
|
|
8019c76e53 | ||
|
|
2afd3bc3c1 | ||
|
|
5b851c11d4 | ||
|
|
2d903c7c5f | ||
|
|
ef7474f636 | ||
|
|
fe1c894b93 | ||
|
|
2815956f5b | ||
|
|
c46ba026ba | ||
|
|
81d6560bf7 | ||
|
|
f35980ba85 | ||
|
|
c6401f1114 | ||
|
|
025f2dbc3f | ||
|
|
85b14e2c2f | ||
|
|
7a8ee0f200 | ||
|
|
2d34d488d7 | ||
|
|
0e07e8409d | ||
|
|
3915ab48f9 | ||
|
|
9aaddbed75 | ||
|
|
253bde1b11 | ||
|
|
61b5bda31a | ||
|
|
e2d611dd81 | ||
|
|
bac40ff268 | ||
|
|
39f2bc05d9 | ||
|
|
76d6f81789 | ||
|
|
8f9ca2800c | ||
|
|
f9bf51badf | ||
|
|
90063a7cfe | ||
|
|
a8491330fa | ||
|
|
064379ca90 | ||
|
|
e90963a6c3 | ||
|
|
064d855382 | ||
|
|
5d67cfcc9e | ||
|
|
b4853b9fd0 | ||
|
|
2d12391176 | ||
|
|
60dc7db925 | ||
|
|
be114e6eaf | ||
|
|
4a59c59bda | ||
|
|
72639c4b90 | ||
|
|
1786406b31 | ||
|
|
12728b2eb4 | ||
|
|
bc4233977d | ||
|
|
fd69eae05d | ||
|
|
d3a3e7b1f1 | ||
|
|
772db11c7f | ||
|
|
4fb020c788 | ||
|
|
932a7a80af | ||
|
|
3ca5843868 | ||
|
|
8ea4cea3ef | ||
|
|
b6c5a80326 | ||
|
|
98796a82c6 | ||
|
|
61562e947d | ||
|
|
609af9fe67 | ||
|
|
e469e7e174 | ||
|
|
c88726db9e | ||
|
|
38986cb82e | ||
|
|
43407016d0 | ||
|
|
7240e0cdac | ||
|
|
7b0c61c18e | ||
|
|
0110cbd1cc | ||
|
|
9663fbf956 | ||
|
|
5177392f46 | ||
|
|
5fc58e3bc7 | ||
|
|
5d2ce68f6b | ||
|
|
70349e5ff4 | ||
|
|
827fa0977b | ||
|
|
e3074c266a | ||
|
|
ab52fdc2b8 | ||
|
|
7424d52f88 | ||
|
|
b3a584a362 | ||
|
|
a2a80ab150 | ||
|
|
d33039f497 |
No files matched your search
@@ -28,3 +28,7 @@ RAYCAST_BEARER=
|
|||||||
# токен от @BotFather, свой user id - @userinfobot
|
# токен от @BotFather, свой user id - @userinfobot
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_USER_ID=
|
TELEGRAM_USER_ID=
|
||||||
|
|
||||||
|
# Откуда тулза send_file имеет право брать файлы (через ':').
|
||||||
|
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
|
||||||
|
SEND_FILE_ROOTS=/mnt/hole/shared
|
||||||
@@ -22,9 +22,13 @@ is a full setup built on them.
|
|||||||
the envelope, the in-process gateway tools.
|
the envelope, the in-process gateway tools.
|
||||||
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
|
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
|
||||||
the subscription budget.
|
the subscription budget.
|
||||||
- **frontends/** - the windows: Telegram (master = General, topic = branch),
|
- **frontends/** - the windows: Telegram (master = General, topic = branch,
|
||||||
|
files out of the agent through `send_file`),
|
||||||
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
||||||
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`.
|
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
|
||||||
|
`WebhookFrontend` for a window a setup declares itself - its own request
|
||||||
|
and response schema, its own agent, its own token scope
|
||||||
|
(`docs/FRONTEND-PLUGINS.md`).
|
||||||
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
|
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
|
||||||
aggregated in-process and handed to agents by name.
|
aggregated in-process and handed to agents by name.
|
||||||
- **vault/** - watching a directory of notes for the envelope.
|
- **vault/** - watching a directory of notes for the envelope.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -33,7 +34,7 @@ from beaver_gateway.frontends.bearer import require_token
|
|||||||
from beaver_gateway.frontends.root import build_root_app
|
from beaver_gateway.frontends.root import build_root_app
|
||||||
from beaver_gateway.jobs.scheduler import Scheduler
|
from beaver_gateway.jobs.scheduler import Scheduler
|
||||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||||
from beaver_gateway.security.auth import TokenStore
|
from beaver_gateway.security.auth import TokenStore, scopes_with
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
Database,
|
Database,
|
||||||
PostgresSessionStore,
|
PostgresSessionStore,
|
||||||
@@ -194,6 +195,8 @@ async def run(gateway: Gateway, settings: Settings) -> None:
|
|||||||
pool=pool,
|
pool=pool,
|
||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
||||||
|
scopes=scopes_with(fe.scope for fe in gateway.frontends),
|
||||||
|
send_file_roots=_roots(settings.send_file_roots),
|
||||||
)
|
)
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
fe.configure(runtime)
|
fe.configure(runtime)
|
||||||
@@ -296,6 +299,10 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
|
|||||||
await uvicorn.Server(config).serve()
|
await uvicorn.Server(config).serve()
|
||||||
|
|
||||||
|
|
||||||
|
def _roots(value: str) -> tuple[Path, ...]:
|
||||||
|
return tuple(Path(p) for p in value.split(":") if p.strip())
|
||||||
|
|
||||||
|
|
||||||
class _LateConversations:
|
class _LateConversations:
|
||||||
conversations: Conversations | None = None
|
conversations: Conversations | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -30,6 +31,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
__all__ = ["ConfigError", "Gateway", "load"]
|
__all__ = ["ConfigError", "Gateway", "load"]
|
||||||
|
|
||||||
|
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Gateway:
|
class Gateway:
|
||||||
@@ -135,6 +138,12 @@ def _validate(gw: Gateway, path: Path) -> None:
|
|||||||
f"got {type(f).__name__}"
|
f"got {type(f).__name__}"
|
||||||
)
|
)
|
||||||
raise ConfigError(msg)
|
raise ConfigError(msg)
|
||||||
|
if f.scope is not None and not _SCOPE.fullmatch(f.scope):
|
||||||
|
msg = (
|
||||||
|
f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; "
|
||||||
|
f"a frontend scope is a lowercase name like 'voice', never '*'"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
names: set[str] = set()
|
names: set[str] = set()
|
||||||
for i, j in enumerate(gw.jobs):
|
for i, j in enumerate(gw.jobs):
|
||||||
if not isinstance(j, Job):
|
if not isinstance(j, Job):
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class RecallContext:
|
|||||||
text: str
|
text: str
|
||||||
kind: str
|
kind: str
|
||||||
now: datetime
|
now: datetime
|
||||||
|
agent: str = ""
|
||||||
|
"""Agent whose turn it is; a setup with several tells them apart by it."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -40,7 +42,12 @@ class Envelope:
|
|||||||
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
||||||
|
|
||||||
def build(
|
def build(
|
||||||
self, *, now: datetime | None = None, text: str = "", kind: str = "master"
|
self,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
text: str = "",
|
||||||
|
kind: str = "master",
|
||||||
|
agent: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
now = now or datetime.now(UTC)
|
now = now or datetime.now(UTC)
|
||||||
changes = self.watch.take() if self.watch is not None else []
|
changes = self.watch.take() if self.watch is not None else []
|
||||||
@@ -59,20 +66,26 @@ class Envelope:
|
|||||||
texts=self.texts,
|
texts=self.texts,
|
||||||
)
|
)
|
||||||
self.last_at = now
|
self.last_at = now
|
||||||
block = self.recall_block(text=text, kind=kind, now=now)
|
block = self.recall_block(text=text, kind=kind, now=now, agent=agent)
|
||||||
return f"{out}\n{block}" if block else out
|
return f"{out}\n{block}" if block else out
|
||||||
|
|
||||||
def recall_only(
|
def recall_only(
|
||||||
self, *, text: str, kind: str, now: datetime | None = None
|
self, *, text: str, kind: str, now: datetime | None = None, agent: str = ""
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
block = self.recall_block(text=text, kind=kind, now=now or datetime.now(UTC))
|
block = self.recall_block(
|
||||||
|
text=text, kind=kind, now=now or datetime.now(UTC), agent=agent
|
||||||
|
)
|
||||||
return f"{self.texts.header}\n{block}" if block else None
|
return f"{self.texts.header}\n{block}" if block else None
|
||||||
|
|
||||||
def recall_block(self, *, text: str, kind: str, now: datetime) -> str | None:
|
def recall_block(
|
||||||
|
self, *, text: str, kind: str, now: datetime, agent: str = ""
|
||||||
|
) -> str | None:
|
||||||
if self.recall is None or not text.strip():
|
if self.recall is None or not text.strip():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
block = self.recall(RecallContext(text=text, kind=kind, now=now))
|
block = self.recall(
|
||||||
|
RecallContext(text=text, kind=kind, now=now, agent=agent)
|
||||||
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("recall hook failed")
|
_log.exception("recall hook failed")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Putting words into a conversation: a message, an inject, ``say``, ``schedule``."""
|
"""Putting words into a conversation: a message, an inject, ``say``, a file."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from beaver_gateway.conversations.turns import Turns
|
from beaver_gateway.conversations.turns import Turns
|
||||||
|
from beaver_gateway.frontends.files import SendFileError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -110,6 +111,31 @@ class Messaging(Turns):
|
|||||||
turn_id=runner.turn_id if runner is not None else None,
|
turn_id=runner.turn_id if runner is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
|
||||||
|
) -> str:
|
||||||
|
"""Hand a file to the human: this conversation's window, else the master's."""
|
||||||
|
for target in (conv, await self.open_master()):
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
for fe in self._frontends:
|
||||||
|
if target.kind in fe.kinds and fe.sends_files:
|
||||||
|
note = await fe.send_file(
|
||||||
|
target, path, caption=caption, method=method
|
||||||
|
)
|
||||||
|
_log.info("send_file[%s]: %s", target.external_id, note)
|
||||||
|
return note
|
||||||
|
msg = (
|
||||||
|
"nowhere to send: no frontend that sends files shows this "
|
||||||
|
"conversation, and there is no open master"
|
||||||
|
)
|
||||||
|
raise SendFileError(msg)
|
||||||
|
|
||||||
|
def file_note(self) -> str:
|
||||||
|
"""What the ``send_file`` tool tells the model about roots and size."""
|
||||||
|
sender = next((f for f in self._frontends if f.sends_files), None)
|
||||||
|
return sender.file_note() if sender else "no frontend here sends files"
|
||||||
|
|
||||||
async def schedule(
|
async def schedule(
|
||||||
self,
|
self,
|
||||||
conv: Conversation,
|
conv: Conversation,
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class UserSaid:
|
|||||||
title: str | None
|
title: str | None
|
||||||
text: str
|
text: str
|
||||||
at: datetime
|
at: datetime
|
||||||
|
agent: str = ""
|
||||||
|
"""Agent whose turn it is; a setup with several tells them apart by it."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from claude_agent_sdk import create_sdk_mcp_server, tool
|
|||||||
|
|
||||||
from beaver_gateway.conversations.injects import URGENCY
|
from beaver_gateway.conversations.injects import URGENCY
|
||||||
from beaver_gateway.conversations.kinds import as_kind
|
from beaver_gateway.conversations.kinds import as_kind
|
||||||
|
from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError
|
||||||
from beaver_gateway.security.redact import redact_data
|
from beaver_gateway.security.redact import redact_data
|
||||||
|
|
||||||
URGENCY_HELP = (
|
URGENCY_HELP = (
|
||||||
@@ -42,7 +43,23 @@ SAY_IN_USER_TURN = (
|
|||||||
"reply text reaches them by itself - put what you wanted to say into the "
|
"reply text reaches them by itself - put what you wanted to say into the "
|
||||||
"reply instead of repeating it here"
|
"reply instead of repeating it here"
|
||||||
)
|
)
|
||||||
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat")
|
TOOL_NAMES = (
|
||||||
|
"read_conversation",
|
||||||
|
"spawn",
|
||||||
|
"say",
|
||||||
|
"schedule",
|
||||||
|
"inject",
|
||||||
|
"close_chat",
|
||||||
|
"send_file",
|
||||||
|
)
|
||||||
|
SEND_FILE_HELP = (
|
||||||
|
"Send a file from disk to the human, into this conversation's window (the "
|
||||||
|
"master's if it has none): {note}. `method` is how Telegram renders it; "
|
||||||
|
"`auto` reads the extension - image is a photo, mp3/flac/m4a audio, ogg "
|
||||||
|
"voice, mp4/mov video, anything else a document. Photo and video are "
|
||||||
|
"recompressed, so pass `document` when the bytes must arrive untouched. "
|
||||||
|
"`caption` takes the same markdown as your replies."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_tool_server(
|
def build_tool_server(
|
||||||
@@ -232,6 +249,33 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|||||||
)
|
)
|
||||||
return _text(f"queued #{item.id}")
|
return _text(f"queued #{item.id}")
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
"send_file",
|
||||||
|
SEND_FILE_HELP.format(note=conversations.file_note()),
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "absolute path on disk"},
|
||||||
|
"caption": {"type": "string"},
|
||||||
|
"method": {"type": "string", "enum": [AUTO, *METHODS], "default": AUTO},
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def send_file(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
conv = await current()
|
||||||
|
try:
|
||||||
|
return _text(
|
||||||
|
await conversations.send_file(
|
||||||
|
conv,
|
||||||
|
str(args["path"]),
|
||||||
|
caption=str(args.get("caption") or ""),
|
||||||
|
method=str(args.get("method") or AUTO),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except SendFileError as exc:
|
||||||
|
return _error(str(exc))
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
"close_chat",
|
"close_chat",
|
||||||
"Close this deep chat once the current reply is finished: the "
|
"Close this deep chat once the current reply is finished: the "
|
||||||
@@ -248,7 +292,7 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|||||||
return _error(str(exc))
|
return _error(str(exc))
|
||||||
return _text("ok: the chat closes after this reply")
|
return _text("ok: the chat closes after this reply")
|
||||||
|
|
||||||
return [read_conversation, spawn, say, schedule, inject, close_chat]
|
return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
|
||||||
|
|
||||||
|
|
||||||
def _text(text: str) -> dict[str, Any]:
|
def _text(text: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -336,9 +336,11 @@ class Turns(Seeds):
|
|||||||
if self._envelope is None:
|
if self._envelope is None:
|
||||||
return None
|
return None
|
||||||
if conv.kind == "master":
|
if conv.kind == "master":
|
||||||
return self._envelope.build(text=text, kind="master")
|
return self._envelope.build(text=text, kind="master", agent=conv.agent_name)
|
||||||
if conv.kind == "branch":
|
if conv.kind == "branch":
|
||||||
return self._envelope.recall_only(text=text, kind="branch")
|
return self._envelope.recall_only(
|
||||||
|
text=text, kind="branch", agent=conv.agent_name
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _note_user(self, conv: Conversation, text: str) -> None:
|
async def _note_user(self, conv: Conversation, text: str) -> None:
|
||||||
@@ -350,6 +352,7 @@ class Turns(Seeds):
|
|||||||
title=conv.title,
|
title=conv.title,
|
||||||
text=text,
|
text=text,
|
||||||
at=datetime.now(UTC),
|
at=datetime.now(UTC),
|
||||||
|
agent=conv.agent_name,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = self._user_sink(message)
|
result = self._user_sink(message)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from beaver_gateway.frontends.sse import (
|
|||||||
)
|
)
|
||||||
from beaver_gateway.frontends.urls import frontend_url
|
from beaver_gateway.frontends.urls import frontend_url
|
||||||
from beaver_gateway.security import audit
|
from beaver_gateway.security import audit
|
||||||
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
|
from beaver_gateway.security.auth import hash_token
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
create_token,
|
create_token,
|
||||||
list_audit_records,
|
list_audit_records,
|
||||||
@@ -217,6 +217,7 @@ def build_app( # noqa: PLR0915
|
|||||||
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
||||||
},
|
},
|
||||||
"path": fe.path,
|
"path": fe.path,
|
||||||
|
"scope": fe.scope,
|
||||||
"url": frontend_url(request, runtime, fe),
|
"url": frontend_url(request, runtime, fe),
|
||||||
}
|
}
|
||||||
for fe in runtime.frontends
|
for fe in runtime.frontends
|
||||||
@@ -814,6 +815,19 @@ def build_app( # noqa: PLR0915
|
|||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/scopes")
|
||||||
|
async def scopes(request: Request) -> dict[str, Any]:
|
||||||
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
by_frontend = {
|
||||||
|
fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"scopes": [
|
||||||
|
{"scope": s, "frontend": by_frontend.get(s)}
|
||||||
|
for s in sorted(runtime.scopes)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/tokens")
|
@app.get("/tokens")
|
||||||
async def tokens(request: Request) -> dict[str, Any]:
|
async def tokens(request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
@@ -828,7 +842,7 @@ def build_app( # noqa: PLR0915
|
|||||||
data = await body_of(request)
|
data = await body_of(request)
|
||||||
name = text_of(data, "name").strip()
|
name = text_of(data, "name").strip()
|
||||||
scope = str(data.get("scope") or "*")
|
scope = str(data.get("scope") or "*")
|
||||||
if scope not in VALID_SCOPES:
|
if scope not in runtime.scopes:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
||||||
plaintext = secrets.token_urlsafe(32)
|
plaintext = secrets.token_urlsafe(32)
|
||||||
async with runtime.db.session() as session:
|
async with runtime.db.session() as session:
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ from abc import ABC, abstractmethod
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from beaver_gateway.frontends.files import SendFileError
|
||||||
|
from beaver_gateway.security.auth import BUILTIN_SCOPES
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from starlette.types import ASGIApp
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
@@ -54,6 +58,10 @@ class GatewayRuntime:
|
|||||||
scheduler: Any = None
|
scheduler: Any = None
|
||||||
public_url: str | None = None
|
public_url: str | None = None
|
||||||
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
|
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
|
||||||
|
scopes: frozenset[str] = BUILTIN_SCOPES
|
||||||
|
"""Every token scope this gateway knows: the builtins plus each frontend's."""
|
||||||
|
send_file_roots: tuple[Path, ...] = ()
|
||||||
|
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
|
||||||
|
|
||||||
|
|
||||||
class Frontend(ABC):
|
class Frontend(ABC):
|
||||||
@@ -64,6 +72,10 @@ class Frontend(ABC):
|
|||||||
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
|
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
|
||||||
``landing`` marks the app that ``/`` redirects to.
|
``landing`` marks the app that ``/`` redirects to.
|
||||||
|
|
||||||
|
``scope`` is the token scope this frontend's routes are gated by; a
|
||||||
|
frontend that names its own scope gets keys nobody else's token opens,
|
||||||
|
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
|
||||||
|
|
||||||
A frontend that shows conversations declares ``name`` (the binding
|
A frontend that shows conversations declares ``name`` (the binding
|
||||||
key) and ``kinds`` (which conversation kinds it shows). The first
|
key) and ``kinds`` (which conversation kinds it shows). The first
|
||||||
frontend whose ``materialize`` returns a binding is the *home* of
|
frontend whose ``materialize`` returns a binding is the *home* of
|
||||||
@@ -76,6 +88,9 @@ class Frontend(ABC):
|
|||||||
kinds: tuple[Kind, ...] = ()
|
kinds: tuple[Kind, ...] = ()
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
landing: bool = False
|
landing: bool = False
|
||||||
|
scope: str | None = None
|
||||||
|
sends_files: bool = False
|
||||||
|
"""Whether ``send_file`` reaches a human through this frontend."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||||
@@ -95,3 +110,19 @@ class Frontend(ABC):
|
|||||||
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
|
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
|
||||||
"""Show in the window that the conversation is over (a renamed topic)."""
|
"""Show in the window that the conversation is over (a renamed topic)."""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self,
|
||||||
|
conv: Conversation, # noqa: ARG002
|
||||||
|
path: str, # noqa: ARG002
|
||||||
|
*,
|
||||||
|
caption: str = "", # noqa: ARG002
|
||||||
|
method: str = "auto", # noqa: ARG002
|
||||||
|
) -> str:
|
||||||
|
"""Put a file in this conversation's window; the answer goes to the model."""
|
||||||
|
msg = f"frontend {self.name!r} cannot send files"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
|
||||||
|
def file_note(self) -> str:
|
||||||
|
"""Roots and ceiling, for the tool description the model reads."""
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""What an agent may hand to a human: an allowed path, a ceiling, a method.
|
||||||
|
|
||||||
|
The ceiling and the method names belong to the frontend's API; the check
|
||||||
|
lives here so every frontend refuses the same way, in words the model reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = ["AUTO", "DEFAULTS", "METHODS", "SendFileError", "SendFiles", "method_for"]
|
||||||
|
|
||||||
|
AUTO = "auto"
|
||||||
|
METHODS = ("document", "photo", "audio", "voice", "video", "video_note", "animation")
|
||||||
|
|
||||||
|
_BY_SUFFIX = {
|
||||||
|
".jpg": "photo",
|
||||||
|
".jpeg": "photo",
|
||||||
|
".png": "photo",
|
||||||
|
".webp": "photo",
|
||||||
|
".gif": "animation",
|
||||||
|
".mp3": "audio",
|
||||||
|
".flac": "audio",
|
||||||
|
".m4a": "audio",
|
||||||
|
".wav": "audio",
|
||||||
|
".aac": "audio",
|
||||||
|
".ogg": "voice",
|
||||||
|
".oga": "voice",
|
||||||
|
".opus": "voice",
|
||||||
|
".mp4": "video",
|
||||||
|
".m4v": "video",
|
||||||
|
".mov": "video",
|
||||||
|
}
|
||||||
|
_BY_TYPE = {"image": "photo", "audio": "audio", "video": "video"}
|
||||||
|
|
||||||
|
|
||||||
|
class SendFileError(Exception):
|
||||||
|
"""Why a file was not sent, worded for the model that asked."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SendFiles:
|
||||||
|
"""Which files a frontend accepts from an agent and how big they may be."""
|
||||||
|
|
||||||
|
roots: tuple[Path, ...] = ()
|
||||||
|
"""Directories a path must resolve inside; nothing outside is sendable."""
|
||||||
|
max_bytes: int = 50 * 1024 * 1024
|
||||||
|
"""Upload ceiling of the API behind the frontend; the Bot API gives 50 MB."""
|
||||||
|
|
||||||
|
def with_roots(self, *roots: Path) -> SendFiles:
|
||||||
|
return replace(self, roots=tuple(dict.fromkeys((*self.roots, *roots))))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def note(self) -> str:
|
||||||
|
"""Roots and ceiling as one line for the tool description."""
|
||||||
|
where = ", ".join(str(r) for r in self.roots) or "none, so nothing is sendable"
|
||||||
|
return f"paths under {where}, up to {mb(self.max_bytes)} MB"
|
||||||
|
|
||||||
|
def resolve(self, raw: str) -> Path:
|
||||||
|
path = Path(raw.strip()).expanduser().resolve()
|
||||||
|
if not any(path.is_relative_to(r.expanduser().resolve()) for r in self.roots):
|
||||||
|
msg = f"{path} is outside the allowed roots ({self.note}); copy it there"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
if not path.is_file():
|
||||||
|
msg = f"{path} is not a file"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size > self.max_bytes:
|
||||||
|
msg = (
|
||||||
|
f"{path.name} is {mb(size)} MB, over the {mb(self.max_bytes)} MB limit"
|
||||||
|
)
|
||||||
|
raise SendFileError(msg)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULTS = SendFiles()
|
||||||
|
|
||||||
|
|
||||||
|
def method_for(path: Path, method: str = AUTO) -> str:
|
||||||
|
if method and method != AUTO:
|
||||||
|
if method not in METHODS:
|
||||||
|
msg = f"unknown method {method!r}; one of: {', '.join((AUTO, *METHODS))}"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
return method
|
||||||
|
guessed = _BY_SUFFIX.get(path.suffix.lower())
|
||||||
|
if guessed:
|
||||||
|
return guessed
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
return _BY_TYPE.get(mime.split("/")[0], "document") if mime else "document"
|
||||||
|
|
||||||
|
|
||||||
|
def mb(size: int) -> str:
|
||||||
|
return f"{round(size / 1024 / 1024, 2):g}"
|
||||||
@@ -21,10 +21,13 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.exceptions import TelegramAPIError
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
|
from aiogram.client.telegram import TelegramAPIServer
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
BotCommand,
|
BotCommand,
|
||||||
CallbackQuery,
|
CallbackQuery,
|
||||||
|
FSInputFile,
|
||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
InlineKeyboardMarkup,
|
InlineKeyboardMarkup,
|
||||||
Message,
|
Message,
|
||||||
@@ -33,10 +36,17 @@ from aiogram.types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from beaver_gateway.frontends.base import Frontend
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
from beaver_gateway.frontends.files import (
|
||||||
|
DEFAULTS,
|
||||||
|
SendFileError,
|
||||||
|
SendFiles,
|
||||||
|
mb,
|
||||||
|
method_for,
|
||||||
|
)
|
||||||
from beaver_gateway.frontends.telegram.drafts import Draft
|
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||||
from beaver_gateway.frontends.telegram.inbox import Inbox
|
from beaver_gateway.frontends.telegram.inbox import Inbox
|
||||||
from beaver_gateway.frontends.telegram.outbox import Outbox
|
from beaver_gateway.frontends.telegram.outbox import Outbox
|
||||||
from beaver_gateway.frontends.telegram.render import chunks, status_label
|
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
|
||||||
from beaver_gateway.frontends.telegram.texts import TelegramTexts
|
from beaver_gateway.frontends.telegram.texts import TelegramTexts
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -53,6 +63,8 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram")
|
|||||||
FRONTEND = "telegram"
|
FRONTEND = "telegram"
|
||||||
_DONE = "done"
|
_DONE = "done"
|
||||||
_COMMANDS = ("merge", "new", "chat", "status", "help", "start")
|
_COMMANDS = ("merge", "new", "chat", "status", "help", "start")
|
||||||
|
_CAPTION = 1024
|
||||||
|
_NO_CAPTION = ("video_note",)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -134,6 +146,7 @@ class _Album:
|
|||||||
class TelegramFrontend(Frontend):
|
class TelegramFrontend(Frontend):
|
||||||
name = FRONTEND
|
name = FRONTEND
|
||||||
kinds = ("master", "branch")
|
kinds = ("master", "branch")
|
||||||
|
sends_files = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -144,6 +157,8 @@ class TelegramFrontend(Frontend):
|
|||||||
branch_agent: str | None = None,
|
branch_agent: str | None = None,
|
||||||
chat_id: int | None = None,
|
chat_id: int | None = None,
|
||||||
attachments: Attachments = EPHEMERAL,
|
attachments: Attachments = EPHEMERAL,
|
||||||
|
send_files: SendFiles = DEFAULTS,
|
||||||
|
api_base_url: str | None = None,
|
||||||
draft_interval: float = 0.7,
|
draft_interval: float = 0.7,
|
||||||
master_topic: str = "🦫 General",
|
master_topic: str = "🦫 General",
|
||||||
queued_reaction: str = "👀",
|
queued_reaction: str = "👀",
|
||||||
@@ -159,6 +174,8 @@ class TelegramFrontend(Frontend):
|
|||||||
self.master_agent = master_agent
|
self.master_agent = master_agent
|
||||||
self.branch_agent = branch_agent
|
self.branch_agent = branch_agent
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
|
self.send_files = send_files
|
||||||
|
self.api_base_url = api_base_url
|
||||||
self.master_topic = master_topic
|
self.master_topic = master_topic
|
||||||
self.draft_interval = draft_interval
|
self.draft_interval = draft_interval
|
||||||
self.queued_reaction = queued_reaction
|
self.queued_reaction = queued_reaction
|
||||||
@@ -187,7 +204,14 @@ class TelegramFrontend(Frontend):
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
if self._bot is None:
|
if self._bot is None:
|
||||||
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None))
|
self._bot = Bot(
|
||||||
|
self._token,
|
||||||
|
session=self._session(),
|
||||||
|
default=DefaultBotProperties(parse_mode=None),
|
||||||
|
)
|
||||||
|
self.send_files = self.send_files.with_roots(
|
||||||
|
*runtime.send_file_roots, self.attachments.root
|
||||||
|
)
|
||||||
self._inbox = Inbox(
|
self._inbox = Inbox(
|
||||||
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
|
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
|
||||||
)
|
)
|
||||||
@@ -195,6 +219,16 @@ class TelegramFrontend(Frontend):
|
|||||||
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
|
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _session(self) -> AiohttpSession | None:
|
||||||
|
"""A local Bot API server instead of api.telegram.org (2 GB uploads)."""
|
||||||
|
if not self.api_base_url:
|
||||||
|
return None
|
||||||
|
return AiohttpSession(
|
||||||
|
api=TelegramAPIServer.from_base(
|
||||||
|
self.api_base_url.rstrip("/"), is_local=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
me = await self.bot.get_me()
|
me = await self.bot.get_me()
|
||||||
_log.info(
|
_log.info(
|
||||||
@@ -251,6 +285,44 @@ class TelegramFrontend(Frontend):
|
|||||||
self._topic_names[target[1]] = f"{prefix}{name}"
|
self._topic_names[target[1]] = f"{prefix}{name}"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def file_note(self) -> str:
|
||||||
|
return self.send_files.note
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
|
||||||
|
) -> str:
|
||||||
|
"""Upload the file into this conversation's topic, caption and all."""
|
||||||
|
target = await self._target_of(conv)
|
||||||
|
if target is None:
|
||||||
|
msg = f"conversation {conv.external_id} has no Telegram window"
|
||||||
|
raise SendFileError(msg)
|
||||||
|
file = self.send_files.resolve(path)
|
||||||
|
how = method_for(file, method)
|
||||||
|
rest = caption.strip()
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"message_thread_id": target[1],
|
||||||
|
how: FSInputFile(file),
|
||||||
|
}
|
||||||
|
if how == "audio":
|
||||||
|
payload["title"] = file.stem
|
||||||
|
if rest and how not in _NO_CAPTION and len(rest) <= _CAPTION:
|
||||||
|
payload |= {"caption": to_html(rest), "parse_mode": "HTML"}
|
||||||
|
rest = ""
|
||||||
|
try:
|
||||||
|
await getattr(self.bot, f"send_{how}")(target[0], **payload)
|
||||||
|
except TelegramNetworkError as exc:
|
||||||
|
msg = f"Telegram did not answer while sending {file.name}: {exc}"
|
||||||
|
raise SendFileError(msg) from exc
|
||||||
|
except TelegramAPIError as exc:
|
||||||
|
msg = f"Telegram refused {file.name} as {how}: {exc}"
|
||||||
|
raise SendFileError(msg) from exc
|
||||||
|
except (OSError, TimeoutError) as exc:
|
||||||
|
msg = f"sending {file.name} failed: {exc}"
|
||||||
|
raise SendFileError(msg) from exc
|
||||||
|
if rest:
|
||||||
|
await self._deliver(conv, rest)
|
||||||
|
return f"sent {file.name} as {how}, {mb(file.stat().st_size)} MB"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot(self) -> Bot:
|
def bot(self) -> Bot:
|
||||||
if self._bot is None:
|
if self._bot is None:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ TOOL_LABELS: dict[str, str] = {
|
|||||||
"mcp__gateway__say": "speaking",
|
"mcp__gateway__say": "speaking",
|
||||||
"mcp__gateway__schedule": "setting a reminder",
|
"mcp__gateway__schedule": "setting a reminder",
|
||||||
"mcp__gateway__inject": "passing to another conversation",
|
"mcp__gateway__inject": "passing to another conversation",
|
||||||
|
"mcp__gateway__send_file": "sending a file",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
|||||||
from beaver_gateway.storage import list_active_tokens, touch_token
|
from beaver_gateway.storage import list_active_tokens, touch_token
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
from beaver_gateway.storage import Database
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ class TokenStoreError(ValueError):
|
|||||||
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
||||||
|
|
||||||
|
|
||||||
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
||||||
"""The scopes a ``Token.scope`` may hold.
|
"""The scopes the gateway's own frontends define.
|
||||||
|
|
||||||
* ``*`` — wildcard, may use any frontend
|
* ``*`` — wildcard, may use any frontend
|
||||||
* ``messages`` — Anthropic Messages frontend only
|
* ``messages`` — Anthropic Messages frontend only
|
||||||
@@ -45,16 +45,24 @@ VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"
|
|||||||
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
||||||
itself authenticates via session cookies, not bearer tokens, so this
|
itself authenticates via session cookies, not bearer tokens, so this
|
||||||
scope is unused today.
|
scope is unused today.
|
||||||
|
|
||||||
|
A setup's own frontend adds its scope on top; see :func:`scopes_with` and
|
||||||
|
``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_with(extra: Iterable[str | None]) -> frozenset[str]:
|
||||||
|
"""The builtin scopes plus every frontend's own; ``None`` entries drop out."""
|
||||||
|
return BUILTIN_SCOPES | {s for s in extra if s}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TokenIdentity:
|
class TokenIdentity:
|
||||||
"""What :meth:`TokenStore.verify` resolves to on success.
|
"""What :meth:`TokenStore.verify` resolves to on success.
|
||||||
|
|
||||||
``token_id`` is the DB row id for persisted tokens, or ``None`` for
|
``token_id`` is the DB row id for persisted tokens, or ``None`` for
|
||||||
an env-bootstrap match (those have no DB row to touch). ``scope``
|
an env-bootstrap match (those have no DB row to touch). ``scope``
|
||||||
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
|
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
|
||||||
bootstrap tokens implicitly get ``"*"``.
|
bootstrap tokens implicitly get ``"*"``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -67,8 +75,8 @@ class TokenIdentity:
|
|||||||
|
|
||||||
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
||||||
Unknown ``required`` values intentionally fall through to a
|
Unknown ``required`` values intentionally fall through to a
|
||||||
strict equality check — callers should pass one of
|
strict equality check — callers should pass a scope the runtime
|
||||||
:data:`VALID_SCOPES`.
|
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
|
||||||
"""
|
"""
|
||||||
return self.scope in ("*", required)
|
return self.scope in ("*", required)
|
||||||
|
|
||||||
@@ -325,11 +333,12 @@ class TokenStore:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"VALID_SCOPES",
|
"BUILTIN_SCOPES",
|
||||||
"TokenIdentity",
|
"TokenIdentity",
|
||||||
"TokenStore",
|
"TokenStore",
|
||||||
"TokenStoreError",
|
"TokenStoreError",
|
||||||
"hash_token",
|
"hash_token",
|
||||||
|
"scopes_with",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ class Settings(BaseSettings):
|
|||||||
"""``Accept-Language`` sent to the Raycast API and the default locale for
|
"""``Accept-Language`` sent to the Raycast API and the default locale for
|
||||||
auto ``UserPreferences``. One value per gateway (one shared Client)."""
|
auto ``UserPreferences``. One value per gateway (one shared Client)."""
|
||||||
|
|
||||||
|
send_file_roots: str = "/mnt/hole/shared"
|
||||||
|
"""Directories the ``send_file`` tool may read, separated by ``:``. A
|
||||||
|
frontend always adds the inbox its own attachments land in."""
|
||||||
|
|
||||||
bootstrap_tokens: str = ""
|
bootstrap_tokens: str = ""
|
||||||
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
|
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
|
||||||
alongside DB-issued tokens; used for first-run setup and recovery."""
|
alongside DB-issued tokens; used for first-run setup and recovery."""
|
||||||
@@ -219,3 +219,25 @@ async def test_failing_recall_or_sink_never_blocks_the_turn(world: World) -> Non
|
|||||||
assert prompt.startswith("hello\n\n" + HEADER)
|
assert prompt.startswith("hello\n\n" + HEADER)
|
||||||
assert root.exists()
|
assert root.exists()
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_recall_hook_and_the_sink_see_whose_turn_it_is(world: World) -> None:
|
||||||
|
root, watch = vault()
|
||||||
|
seen: list[RecallContext] = []
|
||||||
|
|
||||||
|
def recall(ctx: RecallContext) -> str | None:
|
||||||
|
seen.append(ctx)
|
||||||
|
return None if ctx.agent == "a" else "👤 указатель"
|
||||||
|
|
||||||
|
noted: list[UserSaid] = []
|
||||||
|
world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=recall) # noqa: SLF001
|
||||||
|
world.conversations._user_sink = noted.append # noqa: SLF001
|
||||||
|
master = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||||
|
append(root / "люди" / "Прохор.md", "новое\n")
|
||||||
|
watch.note(root / "люди" / "Прохор.md")
|
||||||
|
await world.conversations.post(master, "что там у Прохор")
|
||||||
|
await world.settle(master, 1)
|
||||||
|
assert [ctx.agent for ctx in seen] == ["a"]
|
||||||
|
assert [m.agent for m in noted] == ["a"]
|
||||||
|
assert "👤" not in ScriptedClient.instances[0].prompts[0]
|
||||||
|
await asyncio.sleep(0)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from beaver_gateway.app import AgentRegistry, McpRegistry
|
||||||
|
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||||
|
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||||
|
from beaver_gateway.frontends.bearer import require_token
|
||||||
|
from beaver_gateway.security.auth import BUILTIN_SCOPES, TokenStore, scopes_with
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
|
ROOT = {"Authorization": "Bearer root-key"}
|
||||||
|
VOICE = {"Authorization": "Bearer voice-key"}
|
||||||
|
OPS = {"Authorization": "Bearer ops-key"}
|
||||||
|
|
||||||
|
|
||||||
|
class Speaker(Frontend):
|
||||||
|
"""A frontend a setup declares, gated by a scope of its own."""
|
||||||
|
|
||||||
|
name = "voice"
|
||||||
|
path = "/voice"
|
||||||
|
scope = "voice"
|
||||||
|
|
||||||
|
def configure(self, runtime: GatewayRuntime) -> None:
|
||||||
|
self.runtime = runtime
|
||||||
|
|
||||||
|
def app(self) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.post("/")
|
||||||
|
async def ask(request: Request) -> dict[str, str]:
|
||||||
|
return {"by": await require_token(request, self.runtime, scope=self.scope)}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def runtime() -> GatewayRuntime:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-scopes-"))
|
||||||
|
db = Database(f"sqlite:///{root / 's.db'}")
|
||||||
|
await db.create_all()
|
||||||
|
speaker = Speaker()
|
||||||
|
rt = GatewayRuntime(
|
||||||
|
agents=AgentRegistry([]),
|
||||||
|
mcps=McpRegistry([]),
|
||||||
|
backends={},
|
||||||
|
token_store=TokenStore(
|
||||||
|
db,
|
||||||
|
bootstrap={"root": "root-key", "voice": "voice-key", "ops": "ops-key"},
|
||||||
|
bootstrap_scopes={"voice": "voice", "ops": "api"},
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
frontends=(speaker,),
|
||||||
|
scopes=scopes_with(fe.scope for fe in (speaker,)),
|
||||||
|
)
|
||||||
|
speaker.configure(rt)
|
||||||
|
yield rt
|
||||||
|
await db.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def api(runtime: GatewayRuntime) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=build_api(runtime)), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frontend(runtime: GatewayRuntime) -> httpx.AsyncClient:
|
||||||
|
fe = runtime.frontends[0]
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=fe.app()), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frontend_scope_joins_the_builtin_vocabulary() -> None:
|
||||||
|
assert "voice" not in BUILTIN_SCOPES
|
||||||
|
assert scopes_with(["voice", None, "api"]) == BUILTIN_SCOPES | {"voice"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_scope_of_a_frontend_is_listed_with_its_name(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
listed = await client.get("/scopes", headers=ROOT)
|
||||||
|
scopes = listed.json()["scopes"]
|
||||||
|
assert {"scope": "voice", "frontend": "voice"} in scopes
|
||||||
|
assert {"scope": "api", "frontend": None} in scopes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tokens_mint_for_a_declared_scope_and_not_for_an_unknown_one(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
minted = await client.post(
|
||||||
|
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
|
||||||
|
)
|
||||||
|
unknown = await client.post(
|
||||||
|
"/tokens", json={"name": "nope", "scope": "kitchen"}, headers=ROOT
|
||||||
|
)
|
||||||
|
assert minted.status_code == 201
|
||||||
|
assert minted.json()["token"]["scope"] == "voice"
|
||||||
|
assert unknown.status_code == 400
|
||||||
|
assert "kitchen" in unknown.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_opens_for_its_own_scope_only(runtime: GatewayRuntime) -> None:
|
||||||
|
async with frontend(runtime) as client:
|
||||||
|
own = await client.post("/", headers=VOICE)
|
||||||
|
other = await client.post("/", headers=OPS)
|
||||||
|
wildcard = await client.post("/", headers=ROOT)
|
||||||
|
anonymous = await client.post("/")
|
||||||
|
assert own.status_code == 200
|
||||||
|
assert own.json()["by"] == "voice"
|
||||||
|
assert other.status_code == 403
|
||||||
|
assert wildcard.status_code == 200
|
||||||
|
assert anonymous.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_minted_token_reaches_the_frontend_it_was_minted_for(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
plaintext = (
|
||||||
|
await client.post(
|
||||||
|
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
|
||||||
|
)
|
||||||
|
).json()["plaintext"]
|
||||||
|
async with frontend(runtime) as client:
|
||||||
|
opened = await client.post("/", headers={"X-Api-Key": plaintext})
|
||||||
|
assert opened.status_code == 200
|
||||||
|
assert opened.json()["by"] == "esp32-hall"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import functools
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -14,7 +15,9 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|||||||
|
|
||||||
from beaver_gateway.app import McpRegistry
|
from beaver_gateway.app import McpRegistry
|
||||||
from beaver_gateway.backends.transcript import build_entries
|
from beaver_gateway.backends.transcript import build_entries
|
||||||
|
from beaver_gateway.conversations.tools import _tools
|
||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
|
||||||
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
||||||
from beaver_gateway.frontends.telegram.drafts import Draft
|
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||||
from beaver_gateway.frontends.telegram.render import (
|
from beaver_gateway.frontends.telegram.render import (
|
||||||
@@ -36,6 +39,8 @@ class FakeBot:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.updates: list[dict[str, Any]] = []
|
self.updates: list[dict[str, Any]] = []
|
||||||
self.sent: list[dict[str, Any]] = []
|
self.sent: list[dict[str, Any]] = []
|
||||||
|
self.files: list[dict[str, Any]] = []
|
||||||
|
self.reject_files = False
|
||||||
self.drafts: list[dict[str, Any]] = []
|
self.drafts: list[dict[str, Any]] = []
|
||||||
self.edits: list[dict[str, Any]] = []
|
self.edits: list[dict[str, Any]] = []
|
||||||
self.topics: list[str] = []
|
self.topics: list[str] = []
|
||||||
@@ -132,6 +137,21 @@ class FakeBot:
|
|||||||
async def download(self, file_id, destination=None) -> None:
|
async def download(self, file_id, destination=None) -> None:
|
||||||
Path(destination).write_bytes(b"data")
|
Path(destination).write_bytes(b"data")
|
||||||
|
|
||||||
|
async def _upload(self, method: str, chat_id: int, **kwargs: Any) -> Any:
|
||||||
|
if self.reject_files:
|
||||||
|
raise TelegramBadRequest(
|
||||||
|
method=SendMessage(chat_id=0, text="x"),
|
||||||
|
message="Bad Request: PHOTO_INVALID_DIMENSIONS",
|
||||||
|
)
|
||||||
|
self._message_id += 1
|
||||||
|
self.files.append({"method": method, "chat_id": chat_id, **kwargs})
|
||||||
|
return SimpleNamespace(message_id=self._message_id)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
if name.startswith("send_"):
|
||||||
|
return functools.partial(self._upload, name.removeprefix("send_"))
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
||||||
# helpers for tests
|
# helpers for tests
|
||||||
def push(self, payload: dict[str, Any]) -> None:
|
def push(self, payload: dict[str, Any]) -> None:
|
||||||
payload["update_id"] = 1000 + len(self.updates)
|
payload["update_id"] = 1000 + len(self.updates)
|
||||||
@@ -1035,3 +1055,142 @@ async def test_question_survives_a_gateway_restart(stack: Stack) -> None:
|
|||||||
await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
|
await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
|
||||||
row = await stack.world.conversations.get(master.external_id)
|
row = await stack.world.conversations.get(master.external_id)
|
||||||
assert row.flags.get("ask") is None
|
assert row.flags.get("ask") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _sendable(name: str, body: bytes = b"x") -> tuple[Path, Path]:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-send-")).resolve()
|
||||||
|
file = root / name
|
||||||
|
file.write_bytes(body)
|
||||||
|
return root, file
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_files_takes_only_paths_inside_its_roots() -> None:
|
||||||
|
root, note = _sendable("note.txt")
|
||||||
|
outside = Path(tempfile.mkdtemp(prefix="beaver-out-")).resolve()
|
||||||
|
(outside / "secret.txt").write_bytes(b"y")
|
||||||
|
(root / "escape.txt").symlink_to(outside / "secret.txt")
|
||||||
|
files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
assert files.resolve(f"{root}/../{root.name}/./note.txt") == note
|
||||||
|
|
||||||
|
for bad in (
|
||||||
|
str(outside / "secret.txt"),
|
||||||
|
f"{root}/../{outside.name}/secret.txt",
|
||||||
|
str(root / "escape.txt"),
|
||||||
|
):
|
||||||
|
with pytest.raises(SendFileError, match="outside the allowed roots"):
|
||||||
|
files.resolve(bad)
|
||||||
|
with pytest.raises(SendFileError, match="is not a file"):
|
||||||
|
files.resolve(str(root / "gone.txt"))
|
||||||
|
with pytest.raises(SendFileError, match="outside the allowed roots"):
|
||||||
|
SendFiles().resolve(str(note))
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_files_stops_a_file_over_the_limit() -> None:
|
||||||
|
root, big = _sendable("big.bin", b"0" * (2 * 1024 * 1024))
|
||||||
|
files = SendFiles(roots=(root,), max_bytes=1024 * 1024)
|
||||||
|
with pytest.raises(SendFileError, match="big.bin is 2 MB, over the 1 MB limit"):
|
||||||
|
files.resolve(str(big))
|
||||||
|
assert SendFiles(roots=(root,), max_bytes=4 * 1024 * 1024).resolve(str(big)) == big
|
||||||
|
assert files.note == f"paths under {root}, up to 1 MB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_method_follows_the_extension_unless_it_is_given() -> None:
|
||||||
|
picked = {
|
||||||
|
name: method_for(Path(name))
|
||||||
|
for name in ("a.JPG", "b.gif", "c.mp3", "d.ogg", "e.mov", "f.zip", "g.svg")
|
||||||
|
}
|
||||||
|
assert picked == {
|
||||||
|
"a.JPG": "photo",
|
||||||
|
"b.gif": "animation",
|
||||||
|
"c.mp3": "audio",
|
||||||
|
"d.ogg": "voice",
|
||||||
|
"e.mov": "video",
|
||||||
|
"f.zip": "document",
|
||||||
|
"g.svg": "photo",
|
||||||
|
}
|
||||||
|
assert method_for(Path("a.jpg"), "document") == "document"
|
||||||
|
with pytest.raises(SendFileError, match="unknown method 'sticker'"):
|
||||||
|
method_for(Path("a.jpg"), "sticker")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(stack: Stack, conv, **args: Any) -> dict[str, Any]:
|
||||||
|
tool = next(
|
||||||
|
t
|
||||||
|
for t in _tools(stack.world.conversations, conv.external_id)
|
||||||
|
if t.name == "send_file"
|
||||||
|
)
|
||||||
|
return await tool.handler(args)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_file_lands_in_the_topic_of_the_conversation(stack: Stack) -> None:
|
||||||
|
stack.bot.message("work", thread=11)
|
||||||
|
await stack.until(lambda: stack.sent_with("work"), what="branch reply")
|
||||||
|
branch = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=f"{USER}/11"
|
||||||
|
)
|
||||||
|
root, shot = _sendable("график.png", b"png")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
result = await _send(stack, branch, path=str(shot), caption="**вот**")
|
||||||
|
|
||||||
|
assert result["content"][0]["text"].startswith("sent график.png as photo")
|
||||||
|
(sent,) = stack.bot.files
|
||||||
|
assert sent["method"] == "photo"
|
||||||
|
assert sent["message_thread_id"] == 11
|
||||||
|
assert sent["photo"].path == shot
|
||||||
|
assert sent["caption"] == "<b>вот</b>" and sent["parse_mode"] == "HTML"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_long_caption_follows_the_file_as_a_message(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
root, doc = _sendable("отчёт.pdf")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
await _send(stack, master, path=str(doc), caption="долго " * 300)
|
||||||
|
|
||||||
|
(sent,) = stack.bot.files
|
||||||
|
assert sent["method"] == "document" and "caption" not in sent
|
||||||
|
await stack.until(lambda: stack.sent_with("долго"), what="caption message")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_file_without_a_window_falls_back_to_the_master(
|
||||||
|
stack: Stack,
|
||||||
|
) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
job = await stack.world.conversations.spawn(
|
||||||
|
kind="job", agent="a", seed="brief", text="job", parent=None, origin="test"
|
||||||
|
)
|
||||||
|
root, song = _sendable("Пикник.mp3")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
|
||||||
|
await _send(stack, job, path=str(song))
|
||||||
|
|
||||||
|
(sent,) = stack.bot.files
|
||||||
|
assert sent["method"] == "audio" and sent["title"] == "Пикник"
|
||||||
|
assert (sent["chat_id"], sent["message_thread_id"]) == (USER, 901)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
|
||||||
|
stack.bot.message("hi")
|
||||||
|
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||||
|
master = await stack.world.conversations.find_bound(
|
||||||
|
frontend="telegram", external_id=GENERAL
|
||||||
|
)
|
||||||
|
root, shot = _sendable("bad.png")
|
||||||
|
stack.tg.send_files = SendFiles(roots=(root,))
|
||||||
|
stack.bot.reject_files = True
|
||||||
|
|
||||||
|
result = await _send(stack, master, path=str(shot))
|
||||||
|
|
||||||
|
assert result["is_error"]
|
||||||
|
assert "Telegram refused bad.png as photo" in result["content"][0]["text"]
|
||||||
|
|
||||||
|
outside = await _send(stack, master, path="/etc/passwd")
|
||||||
|
assert outside["is_error"]
|
||||||
|
assert "outside the allowed roots" in outside["content"][0]["text"]
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
LimitsResponse,
|
LimitsResponse,
|
||||||
MemoryFile,
|
MemoryFile,
|
||||||
MemoryTree,
|
MemoryTree,
|
||||||
|
ScopeRow,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
SessionsResponse,
|
SessionsResponse,
|
||||||
TokenRow,
|
TokenRow,
|
||||||
@@ -351,6 +352,10 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scopes(): Promise<{ scopes: ScopeRow[] }> {
|
||||||
|
return this.get("/api/scopes");
|
||||||
|
}
|
||||||
|
|
||||||
createToken(
|
createToken(
|
||||||
name: string,
|
name: string,
|
||||||
scope: string
|
scope: string
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export interface FrontendInfo {
|
|||||||
kinds: Kind[];
|
kinds: Kind[];
|
||||||
name: string;
|
name: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
scope: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
@@ -245,6 +246,11 @@ export interface MemoryFile {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScopeRow {
|
||||||
|
frontend: string | null;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenRow {
|
export interface TokenRow {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -117,7 +117,17 @@
|
|||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
|
endpoints: base
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
hint: fe.scope
|
||||||
|
? `POST the frontend's own payload; bearer with scope ${fe.scope}`
|
||||||
|
: "",
|
||||||
|
label: "base",
|
||||||
|
url: base,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
frontend: fe,
|
frontend: fe,
|
||||||
note: "",
|
note: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import type { TokenRow } from "$lib/api/types";
|
import type { ScopeRow, TokenRow } from "$lib/api/types";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -15,13 +15,23 @@
|
|||||||
import { session } from "$lib/session.svelte";
|
import { session } from "$lib/session.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
const SCOPES = [
|
const HINTS: Record<string, string> = {
|
||||||
{ hint: "every frontend", value: "*" },
|
"*": "every frontend",
|
||||||
{ hint: "conversations API and SSE (panel, plugin)", value: "api" },
|
admin: "tokens and audit over the API",
|
||||||
{ hint: "Anthropic /v1/messages", value: "messages" },
|
api: "conversations API and SSE (panel, plugin)",
|
||||||
{ hint: "MCP server", value: "mcp" },
|
mcp: "MCP server",
|
||||||
{ hint: "tokens and audit over the API", value: "admin" },
|
messages: "Anthropic /v1/messages",
|
||||||
];
|
};
|
||||||
|
|
||||||
|
let scopes = $state<ScopeRow[]>([]);
|
||||||
|
const options = $derived(
|
||||||
|
scopes.map((row) => ({
|
||||||
|
hint: row.frontend
|
||||||
|
? `the ${row.frontend} frontend`
|
||||||
|
: (HINTS[row.scope] ?? ""),
|
||||||
|
value: row.scope,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
let tokens = $state<TokenRow[] | null>(null);
|
let tokens = $state<TokenRow[] | null>(null);
|
||||||
let includeRevoked = $state(false);
|
let includeRevoked = $state(false);
|
||||||
@@ -49,6 +59,21 @@
|
|||||||
load(includeRevoked);
|
load(includeRevoked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { client } = session;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client
|
||||||
|
.scopes()
|
||||||
|
.then(({ scopes: rows }) => {
|
||||||
|
scopes = rows;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
scopes = [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
const { client } = session;
|
const { client } = session;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -254,7 +279,7 @@
|
|||||||
>
|
>
|
||||||
<Select.Trigger class="w-full">{scope}</Select.Trigger>
|
<Select.Trigger class="w-full">{scope}</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each SCOPES as option (option.value)}
|
{#each options as option (option.value)}
|
||||||
<Select.Item label={option.value} value={option.value}>
|
<Select.Item label={option.value} value={option.value}>
|
||||||
<span class="flex flex-col">
|
<span class="flex flex-col">
|
||||||
<span>{option.value}</span>
|
<span>{option.value}</span>
|
||||||
|
|||||||
Reference in new issue
Block a user