mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-14 22:07:13 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3d541876 | |||
| 60bb67f025 | |||
| cbe6844342 | |||
| 940895c541 | |||
| 9b130680ca | |||
| d26d0635c5 | |||
| 58cde8c022 | |||
| 9eb8683997 | |||
| 19526e1400 | |||
| dfde9578c1 | |||
| ec5b8cc8a8 | |||
| 137bb4fdb3 | |||
| 273b8839b9 | |||
| 9db6650274 | |||
| b594cf23e4 | |||
| 45d77c3926 | |||
| 1ea9cd3f6f | |||
| ff6ad6adc2 | |||
| ce6b647fe7 | |||
| 485113ae43 | |||
| 1ba3c1ec72 | |||
| 3a82aa5fcc | |||
| fc1c998434 | |||
| d90253cc77 | |||
| d8b3dad212 | |||
| 8281eb18c9 | |||
| 4eea3419e8 | |||
| 814740ea68 | |||
| e6e8258545 | |||
| 1a69747a06 | |||
| 1fa5e2f755 | |||
| 267232c24f | |||
| d6443b10bc | |||
| 0577689da4 | |||
| bc32eb3ca0 | |||
| 0f216a0d4d | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 |
@@ -1,5 +1,5 @@
|
||||
name: 漏洞反馈
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
@@ -22,7 +22,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 异常情况
|
||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Discord release announcements
|
||||
|
||||
This independent workflow announces new stable LangBot releases in the channel
|
||||
selected by a dedicated Discord incoming webhook. It does not change the existing
|
||||
release/build workflows, edit releases, run a persistent service, poll, or backfill.
|
||||
Announcements run on publication, independently of artifact builds finishing.
|
||||
|
||||
## Setup and read-only validation
|
||||
|
||||
1. In the intended community **announcement channel**, create a dedicated incoming
|
||||
webhook (Channel Settings → Integrations → Webhooks). Copy its URL; do not reuse
|
||||
a webhook belonging to another automation.
|
||||
2. In `langbot-app/LangBot` → Settings → Secrets and variables → Actions, create the
|
||||
**repository secret** `DISCORD_RELEASE_WEBHOOK_URL`. Its value must be exactly
|
||||
`https://discord.com/api/webhooks/<id>/<token>` — no query, trailing slash,
|
||||
API-version segment, or alternate domain. Treat the entire URL as a password.
|
||||
3. Once this workflow is on `master`, open Actions → **Discord Release Announcement**
|
||||
→ Run workflow, choosing `master`. Alternatively:
|
||||
|
||||
```sh
|
||||
gh workflow run discord-release.yml --repo langbot-app/LangBot --ref master
|
||||
```
|
||||
|
||||
4. Inspect **Validate webhook (GET only, no message)**. It checks webhook type `1`
|
||||
and reports `guild_id` and `channel_id`; compare both with the intended server
|
||||
and channel using Discord Developer Mode → Copy ID. The secret determines the
|
||||
destination; no channel ID is guessed or overridden. The URL/token is never
|
||||
logged. Dispatch cannot send a test message or announce an old release, even
|
||||
when run again. Missing/invalid secrets fail validation clearly; offline tests
|
||||
do not need secrets.
|
||||
|
||||
GET validation confirms the webhook's identity, not delivery or notification
|
||||
permissions. Verify those on the first genuine release. `mention_everyone=true`
|
||||
confirms Discord parsed the mention; it cannot prove every member received a push
|
||||
notification (member/server notification settings still apply).
|
||||
|
||||
## Activation and message
|
||||
|
||||
The workflow and `.github/discord-release/` helper **must be in the commit targeted
|
||||
by each new release tag**. Merging to `master` does not enable announcements for
|
||||
old tags whose commits lack these files. Manual dispatch becomes available when
|
||||
the workflow is on the default branch. Only publish release tags from trusted,
|
||||
reviewed commits: release workflows execute that tag's code with the secret.
|
||||
|
||||
Only `release` events with action `published`, `draft=false`, and
|
||||
`prerelease=false` can send. Drafts and prereleases are skipped; release edits do
|
||||
not trigger announcements. The helper requires the repository to be exactly
|
||||
`langbot-app/LangBot`, a stable `vX.Y.Z` tag (ASCII digits, at most 64 characters),
|
||||
and its exact canonical GitHub release URL. Other naming schemes fail closed.
|
||||
|
||||
Example message (the version and URL come from the validated event file):
|
||||
|
||||
```text
|
||||
@everyone LangBot v4.10.11 is now available!
|
||||
Release notes: https://github.com/langbot-app/LangBot/releases/tag/v4.10.11
|
||||
```
|
||||
|
||||
The release title/body is never copied. There is one literal `@everyone`, explicit
|
||||
`allowed_mentions.parse=["everyone"]`, empty user/role allowlists, and no reply
|
||||
mention. TTS and notification-suppressing flags are disabled. Requests use HTTPS
|
||||
only to `discord.com`, an explicit User-Agent, and no redirects or automatic
|
||||
retries. After a webhook identity GET, one `POST ?wait=true` obtains a message ID;
|
||||
an exact `/messages/<id>` GET verifies its ID, webhook/channel, content,
|
||||
`mention_everyone=true`, and empty user/role mention arrays before success.
|
||||
|
||||
## Repeat guard and manual recovery
|
||||
|
||||
Production sending requires **`GITHUB_RUN_ATTEMPT == "1"`**. Any Actions rerun
|
||||
(including “Re-run failed jobs”) refuses to POST and requires manual reconciliation,
|
||||
even if the first attempt failed before sending. Read-only dispatch may be rerun.
|
||||
|
||||
This is a practical repeat guard, **not durable exactly-once delivery**. It cannot
|
||||
prevent duplicates from a separate new run/event (for example deleting/recreating
|
||||
a release), separate automation, or manual posting. It stores no durable dedupe
|
||||
state and never modifies the release to mark delivery.
|
||||
|
||||
If a POST times out, returns an error, or readback fails, the message may already
|
||||
exist. The workflow fails rather than blindly sending again. A returned message ID
|
||||
is included in the safe error when available. A runner termination can also leave
|
||||
an ambiguous send without that log line.
|
||||
|
||||
1. Inspect the announcement channel and the failed run logs. Locate the canonical
|
||||
release link and, if available, the returned message ID. A failed verification
|
||||
does **not** mean the message was absent.
|
||||
2. If present, reconcile the existing message/mention problem manually; do not
|
||||
rerun, create another release event, or send a duplicate ping.
|
||||
3. If an operator has positively confirmed no message exists, fix the secret or
|
||||
permission issue and use read-only dispatch to validate configuration. A
|
||||
maintainer may then post the announcement manually once in Discord and record
|
||||
the message link in the incident/run notes. Do not override the attempt guard
|
||||
or delete/recreate a release to force recovery.
|
||||
4. If absence cannot be established, pause and reconcile rather than resending.
|
||||
|
||||
To stop future sends, disable **Discord Release Announcement** in Actions. Rotate
|
||||
or delete the dedicated Discord webhook if the URL is exposed, and update the
|
||||
secret before validation. No rollback of release artifacts is involved.
|
||||
|
||||
## Local checks
|
||||
|
||||
Requires Python 3.11+ and the standard library only:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v
|
||||
python3 -m py_compile .github/discord-release/announce.py .github/discord-release/test_announce.py
|
||||
```
|
||||
|
||||
Tests exercise policy, CLI/event-file handling, mention payloads, hostile inputs,
|
||||
HTTP failures, exact message readback, and refusal to retry. Only the HTTPS
|
||||
transport is mocked for Discord tests; no live Discord requests or messages are
|
||||
made. Changes to this directory or its workflow run the offline tests on push and
|
||||
pull request; tests also gate release sending and read-only dispatch validation.
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Announce only first-attempt stable releases; dispatch is read-only validation."""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPOSITORY = 'langbot-app/LangBot'
|
||||
RELEASE_PREFIX = f'https://github.com/{REPOSITORY}/releases/tag/'
|
||||
RECONCILE = (
|
||||
'Do not resend or bypass the run-attempt guard; manual reconciliation is required. '
|
||||
'Inspect the announcement channel and workflow logs before any manual recovery '
|
||||
'(see .github/discord-release/README.md).'
|
||||
)
|
||||
|
||||
|
||||
class AnnouncementError(Exception):
|
||||
"""A safe, operator-facing error containing no webhook URL or response body."""
|
||||
|
||||
|
||||
def release_payload(event, attempt):
|
||||
"""Return a bounded, mention-safe payload, or None for draft/preview releases."""
|
||||
if not isinstance(event, dict) or event.get('action') != 'published':
|
||||
raise AnnouncementError('Only release.published events are accepted.')
|
||||
repository = event.get('repository')
|
||||
if not isinstance(repository, dict) or repository.get('full_name') != REPOSITORY:
|
||||
raise AnnouncementError('Unexpected release repository.')
|
||||
release = event.get('release')
|
||||
if not isinstance(release, dict) or any(type(release.get(key)) is not bool for key in ('draft', 'prerelease')):
|
||||
raise AnnouncementError('Invalid release flags.')
|
||||
if release['draft'] or release['prerelease']:
|
||||
return None
|
||||
if attempt != '1':
|
||||
raise AnnouncementError(f'Release reruns or missing run attempts are refused. {RECONCILE}')
|
||||
tag = release.get('tag_name')
|
||||
if not isinstance(tag, str) or len(tag) > 64 or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
|
||||
raise AnnouncementError('Expected a stable release tag in vX.Y.Z format (at most 64 characters).')
|
||||
url = RELEASE_PREFIX + tag
|
||||
if release.get('html_url') != url:
|
||||
raise AnnouncementError('Release URL must be the canonical LangBot release URL matching its tag.')
|
||||
return {
|
||||
'content': f'@everyone LangBot {tag} is now available!\nRelease notes: {url}',
|
||||
'allowed_mentions': {'parse': ['everyone'], 'users': [], 'roles': [], 'replied_user': False},
|
||||
'tts': False,
|
||||
'flags': 0,
|
||||
}
|
||||
|
||||
|
||||
def is_snowflake(value):
|
||||
return isinstance(value, str) and re.fullmatch(r'[0-9]{1,20}', value) is not None
|
||||
|
||||
|
||||
class DiscordWebhook:
|
||||
def __init__(self, url):
|
||||
if not url:
|
||||
raise AnnouncementError('DISCORD_RELEASE_WEBHOOK_URL is missing. Set the repository Actions secret.')
|
||||
match = re.fullmatch(r'https://discord\.com(/api/webhooks/([0-9]{1,20})/[A-Za-z0-9_-]+)', url)
|
||||
if not match:
|
||||
raise AnnouncementError('Invalid webhook URL; expected https://discord.com/api/webhooks/<id>/<token>.')
|
||||
self.path, self.id = match.groups()
|
||||
|
||||
def _request(self, method, suffix='', payload=None):
|
||||
# Direct HTTPS, default certificate verification, no proxies or redirect/retry machinery.
|
||||
connection = http.client.HTTPSConnection('discord.com', timeout=20)
|
||||
try:
|
||||
body = json.dumps(payload).encode('utf-8') if payload is not None else None
|
||||
connection.request(
|
||||
method,
|
||||
self.path + suffix,
|
||||
body=body,
|
||||
headers={'Content-Type': 'application/json', 'User-Agent': 'LangBot-Release-Announcements/1.0'},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise AnnouncementError(f'Discord {method} returned HTTP {response.status}; no retry was attempted.')
|
||||
raw = response.read(1_048_577)
|
||||
if len(raw) > 1_048_576:
|
||||
raise AnnouncementError('Discord response exceeded the size limit.')
|
||||
return json.loads(raw)
|
||||
except (OSError, http.client.HTTPException, ValueError, UnicodeError):
|
||||
# Exceptions and bodies can contain the token; never print them or chain them.
|
||||
raise AnnouncementError(
|
||||
f'Discord {method} failed or returned invalid JSON; no retry was attempted.'
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def validate(self):
|
||||
"""GET only: verify an incoming webhook and return safe identifying fields."""
|
||||
webhook = self._request('GET')
|
||||
if (
|
||||
not isinstance(webhook, dict)
|
||||
or type(webhook.get('type')) is not int
|
||||
or webhook['type'] != 1
|
||||
or webhook.get('id') != self.id
|
||||
or not is_snowflake(webhook.get('guild_id'))
|
||||
or not is_snowflake(webhook.get('channel_id'))
|
||||
):
|
||||
raise AnnouncementError('Expected an incoming (type 1) webhook with matching ID and guild/channel IDs.')
|
||||
return {key: webhook[key] for key in ('id', 'type', 'guild_id', 'channel_id')}
|
||||
|
||||
def send(self, payload):
|
||||
"""One POST, followed by exact message GET; never automatically retry a send."""
|
||||
webhook = self.validate()
|
||||
message_id = None
|
||||
try:
|
||||
sent = self._request('POST', '?wait=true', payload)
|
||||
if not isinstance(sent, dict) or not is_snowflake(sent.get('id')):
|
||||
raise AnnouncementError('Discord did not return a valid message ID.')
|
||||
message_id = sent['id']
|
||||
saved = self._request('GET', f'/messages/{message_id}')
|
||||
if (
|
||||
not isinstance(saved, dict)
|
||||
or saved.get('id') != message_id
|
||||
or saved.get('webhook_id') != self.id
|
||||
or saved.get('channel_id') != webhook['channel_id']
|
||||
or saved.get('content') != payload['content']
|
||||
or saved.get('mention_everyone') is not True
|
||||
or saved.get('mentions') != []
|
||||
or saved.get('mention_roles') != []
|
||||
):
|
||||
raise AnnouncementError('Discord message readback did not match content, identity, or mentions.')
|
||||
except AnnouncementError as error:
|
||||
reference = f' Returned message ID: {message_id}.' if message_id else ''
|
||||
raise AnnouncementError(f'Delivery not confirmed. {error}{reference} {RECONCILE}') from None
|
||||
return message_id
|
||||
|
||||
|
||||
def main(env=None):
|
||||
env = os.environ if env is None else env
|
||||
try:
|
||||
if env.get('GITHUB_REPOSITORY') != REPOSITORY:
|
||||
raise AnnouncementError('This workflow is restricted to langbot-app/LangBot.')
|
||||
name = env.get('GITHUB_EVENT_NAME')
|
||||
if name == 'workflow_dispatch':
|
||||
webhook = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).validate()
|
||||
print(
|
||||
f'Validated incoming webhook: guild_id={webhook["guild_id"]} channel_id={webhook["channel_id"]}. No message sent.'
|
||||
)
|
||||
return 0
|
||||
if name != 'release':
|
||||
raise AnnouncementError('Only release and workflow_dispatch events are accepted by this helper.')
|
||||
try:
|
||||
event = json.loads(Path(env.get('GITHUB_EVENT_PATH', '')).read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
raise AnnouncementError('Cannot read a valid JSON release event from GITHUB_EVENT_PATH.') from None
|
||||
payload = release_payload(event, env.get('GITHUB_RUN_ATTEMPT'))
|
||||
if payload is None:
|
||||
print('Skipped draft or prerelease; no message sent.')
|
||||
return 0
|
||||
message_id = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).send(payload)
|
||||
print(f'Announcement verified by exact message readback: message_id={message_id}.')
|
||||
return 0
|
||||
except AnnouncementError as error:
|
||||
print(f'Error: {error}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Offline contract tests; no Discord credentials or network required."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
try:
|
||||
import announce
|
||||
except ModuleNotFoundError:
|
||||
announce = None
|
||||
|
||||
WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/fixture_token-ONLY'
|
||||
WEBHOOK_ID = '123456789012345678'
|
||||
GUILD_ID = '234567890123456789'
|
||||
CHANNEL_ID = '345678901234567890'
|
||||
MESSAGE_ID = '456789012345678901'
|
||||
REPO = 'langbot-app/LangBot'
|
||||
URL = f'https://github.com/{REPO}/releases/tag/v4.10.11'
|
||||
CONTENT = f'@everyone LangBot v4.10.11 is now available!\nRelease notes: {URL}'
|
||||
|
||||
|
||||
def event():
|
||||
return {
|
||||
'action': 'published',
|
||||
'repository': {'full_name': REPO},
|
||||
'release': {
|
||||
'draft': False,
|
||||
'prerelease': False,
|
||||
'tag_name': 'v4.10.11',
|
||||
'html_url': URL,
|
||||
'name': 'Hostile @everyone <@123> $(touch /tmp/unsafe)',
|
||||
'body': '@everyone @here <@123> <@&456> `hostile`',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def metadata():
|
||||
return {'id': WEBHOOK_ID, 'type': 1, 'guild_id': GUILD_ID, 'channel_id': CHANNEL_ID}
|
||||
|
||||
|
||||
def message():
|
||||
return {
|
||||
'id': MESSAGE_ID,
|
||||
'webhook_id': WEBHOOK_ID,
|
||||
'channel_id': CHANNEL_ID,
|
||||
'content': CONTENT,
|
||||
'mention_everyone': True,
|
||||
'mentions': [],
|
||||
'mention_roles': [],
|
||||
}
|
||||
|
||||
|
||||
class BaseTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(announce, 'The release announcement helper must exist')
|
||||
|
||||
|
||||
class PolicyTests(BaseTest):
|
||||
def test_payload_has_one_literal_everyone_and_no_untrusted_body(self):
|
||||
payload = announce.release_payload(event(), '1')
|
||||
self.assertEqual(payload['content'], CONTENT)
|
||||
self.assertEqual(json.dumps(payload).count('@everyone'), 1)
|
||||
self.assertEqual(
|
||||
payload['allowed_mentions'],
|
||||
{
|
||||
'parse': ['everyone'],
|
||||
'users': [],
|
||||
'roles': [],
|
||||
'replied_user': False,
|
||||
},
|
||||
)
|
||||
self.assertIs(payload['tts'], False)
|
||||
self.assertEqual(payload['flags'], 0)
|
||||
|
||||
def test_drafts_and_prereleases_are_skipped(self):
|
||||
for flag in ('draft', 'prerelease'):
|
||||
with self.subTest(flag=flag):
|
||||
value = event()
|
||||
value['release'][flag] = True
|
||||
self.assertIsNone(announce.release_payload(value, '1'))
|
||||
|
||||
def test_only_published_action_is_accepted(self):
|
||||
for action in ('edited', 'created', 'released', 'deleted', '', None):
|
||||
with self.subTest(action=action):
|
||||
value = event()
|
||||
value['action'] = action
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_reruns_and_missing_attempt_refuse_manual_reconciliation(self):
|
||||
for attempt in ('2', '3', '', None, '01', '0', '1\n'):
|
||||
with self.subTest(attempt=attempt):
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
|
||||
announce.release_payload(event(), attempt)
|
||||
|
||||
def test_repository_must_match_exactly(self):
|
||||
for repo in ('evil/LangBot', 'langbot-app/langbot', None):
|
||||
value = event()
|
||||
value['repository']['full_name'] = repo
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_hostile_and_noncanonical_tags_are_rejected(self):
|
||||
for tag in (
|
||||
'v1.2.3 @everyone',
|
||||
'v1.2.3\n',
|
||||
'v1.2.3/../../x',
|
||||
'v1.2.3?x=y',
|
||||
'$(id)',
|
||||
'v1.2.3-rc.1',
|
||||
'v1.2.3',
|
||||
'v1.2.3%0a',
|
||||
'<@123>',
|
||||
'v1.2.' + '3' * 100,
|
||||
'',
|
||||
None,
|
||||
123,
|
||||
):
|
||||
with self.subTest(tag=tag):
|
||||
value = event()
|
||||
value['release']['tag_name'] = tag
|
||||
value['release']['html_url'] = f'https://github.com/{REPO}/releases/tag/{tag}'
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_release_url_must_be_canonical_and_match_tag(self):
|
||||
for url in (
|
||||
'https://evil.example/tag/v4.10.11',
|
||||
URL + '?x=y',
|
||||
URL + '#anchor',
|
||||
URL + '/',
|
||||
URL.replace('v4.10.11', 'v4.10.12'),
|
||||
URL.replace('github.com', 'github.com@evil.example'),
|
||||
URL.replace('https:', 'http:'),
|
||||
URL + '\n',
|
||||
None,
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
value = event()
|
||||
value['release']['html_url'] = url
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_malformed_events_fail_closed(self):
|
||||
for value in (None, [], {}, {'release': []}, {'repository': None}):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
for flag in ('draft', 'prerelease'):
|
||||
for bad in (None, 'false', 0, 1):
|
||||
value = event()
|
||||
value['release'][flag] = bad
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
|
||||
class DiscordTests(BaseTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch = patch('announce.http.client.HTTPSConnection')
|
||||
self.connection_class = self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
self.connection = self.connection_class.return_value
|
||||
|
||||
def respond(self, *values):
|
||||
responses = []
|
||||
for value in values:
|
||||
response = MagicMock()
|
||||
response.status = 200
|
||||
response.read.return_value = json.dumps(value).encode()
|
||||
responses.append(response)
|
||||
self.connection.getresponse.side_effect = responses
|
||||
|
||||
def methods(self):
|
||||
return [call.args[0] for call in self.connection.request.call_args_list]
|
||||
|
||||
def test_webhook_validation_is_get_only_and_reports_ids(self):
|
||||
self.respond(metadata())
|
||||
result = announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertEqual(result, metadata())
|
||||
self.assertEqual(self.methods(), ['GET'])
|
||||
self.assertEqual(
|
||||
self.connection.request.call_args.args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY')
|
||||
)
|
||||
self.connection_class.assert_called_with('discord.com', timeout=20)
|
||||
self.connection.close.assert_called_once()
|
||||
|
||||
def test_invalid_webhook_urls_are_rejected_before_network(self):
|
||||
for url in (
|
||||
'',
|
||||
None,
|
||||
WEBHOOK + '/',
|
||||
WEBHOOK + '?wait=true',
|
||||
WEBHOOK + '#x',
|
||||
WEBHOOK + '\n',
|
||||
' ' + WEBHOOK,
|
||||
WEBHOOK.replace('https:', 'http:'),
|
||||
WEBHOOK.replace('discord.com', 'discord.com.evil.example'),
|
||||
WEBHOOK.replace('discord.com', 'discord.com@evil.example'),
|
||||
WEBHOOK.replace('discord.com', 'discord.com:443'),
|
||||
WEBHOOK.replace('/api/', '/api/v10/'),
|
||||
WEBHOOK.replace(WEBHOOK_ID, 'abc'),
|
||||
WEBHOOK + '/../../x',
|
||||
WEBHOOK.replace('fixture_token-ONLY', 'a%2Fb'),
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(url)
|
||||
self.connection_class.assert_not_called()
|
||||
|
||||
def test_webhook_metadata_requires_incoming_type_and_ids(self):
|
||||
invalid = [
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
dict(metadata(), type=2),
|
||||
dict(metadata(), type=True),
|
||||
dict(metadata(), id='999'),
|
||||
dict(metadata(), channel_id=None),
|
||||
dict(metadata(), guild_id='::error::hostile'),
|
||||
]
|
||||
for value in invalid:
|
||||
with self.subTest(value=value):
|
||||
self.respond(value)
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertNotIn('POST', self.methods())
|
||||
|
||||
def test_send_waits_and_reads_back_exact_returned_message(self):
|
||||
self.respond(metadata(), message(), message())
|
||||
result = announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertEqual(result, MESSAGE_ID)
|
||||
self.assertEqual(self.methods(), ['GET', 'POST', 'GET'])
|
||||
calls = self.connection.request.call_args_list
|
||||
self.assertEqual(calls[1].args[:2], ('POST', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY?wait=true'))
|
||||
self.assertEqual(json.loads(calls[1].kwargs['body']), announce.release_payload(event(), '1'))
|
||||
self.assertEqual(
|
||||
calls[2].args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY/messages/{MESSAGE_ID}')
|
||||
)
|
||||
|
||||
def test_readback_must_match_content_mentions_and_identity(self):
|
||||
for field, bad in (
|
||||
('content', 'wrong'),
|
||||
('mention_everyone', False),
|
||||
('mention_everyone', 1),
|
||||
('mentions', [{'id': '123'}]),
|
||||
('mention_roles', ['123']),
|
||||
('id', '999'),
|
||||
('channel_id', '999'),
|
||||
('webhook_id', '999'),
|
||||
):
|
||||
with self.subTest(field=field, bad=bad):
|
||||
self.connection.reset_mock()
|
||||
self.respond(metadata(), message(), dict(message(), **{field: bad}))
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertEqual(self.methods().count('POST'), 1)
|
||||
|
||||
def test_missing_readback_fields_fail_closed(self):
|
||||
for field in message():
|
||||
value = message()
|
||||
del value[field]
|
||||
self.respond(metadata(), message(), value)
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
|
||||
def test_unsafe_post_message_id_never_becomes_get_path(self):
|
||||
for value in (None, {}, dict(message(), id='../evil'), dict(message(), id='123?x=y')):
|
||||
self.connection.reset_mock()
|
||||
self.respond(metadata(), value)
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertEqual(self.methods(), ['GET', 'POST'])
|
||||
|
||||
def test_post_failure_never_retries_and_never_logs_secret(self):
|
||||
for status in (301, 302, 307, 308, 400, 401, 403, 429, 500, 204):
|
||||
with self.subTest(status=status):
|
||||
self.connection.reset_mock()
|
||||
self.respond(metadata(), message())
|
||||
responses = list(self.connection.getresponse.side_effect)
|
||||
responses[1].status = status
|
||||
self.connection.getresponse.side_effect = responses
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught:
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertNotIn('fixture_token', str(caught.exception))
|
||||
self.assertEqual(self.methods(), ['GET', 'POST'])
|
||||
|
||||
def test_ambiguous_timeout_never_retries_or_echoes_exception(self):
|
||||
self.respond(metadata())
|
||||
first = next(self.connection.getresponse.side_effect)
|
||||
self.connection.getresponse.side_effect = [first, TimeoutError(WEBHOOK)]
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught:
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertNotIn('fixture_token', str(caught.exception))
|
||||
self.assertEqual(self.methods(), ['GET', 'POST'])
|
||||
|
||||
def test_malformed_json_response_is_sanitized(self):
|
||||
self.respond(metadata())
|
||||
response = next(self.connection.getresponse.side_effect)
|
||||
response.read.return_value = WEBHOOK.encode()
|
||||
self.connection.getresponse.side_effect = [response]
|
||||
with self.assertRaises(announce.AnnouncementError) as caught:
|
||||
announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertNotIn('fixture_token', str(caught.exception))
|
||||
|
||||
def test_get_redirect_is_not_followed(self):
|
||||
self.respond(metadata())
|
||||
response = next(self.connection.getresponse.side_effect)
|
||||
response.status = 302
|
||||
response.getheader.return_value = 'https://evil.example/'
|
||||
self.connection.getresponse.side_effect = [response]
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertEqual(self.methods(), ['GET'])
|
||||
|
||||
|
||||
class EntrypointTests(BaseTest):
|
||||
def run_main(self, data=None, **overrides):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'event.json'
|
||||
path.write_text(json.dumps(event() if data is None else data))
|
||||
env = {
|
||||
'GITHUB_EVENT_NAME': 'release',
|
||||
'GITHUB_EVENT_PATH': str(path),
|
||||
'GITHUB_REPOSITORY': REPO,
|
||||
'GITHUB_RUN_ATTEMPT': '1',
|
||||
'DISCORD_RELEASE_WEBHOOK_URL': WEBHOOK,
|
||||
}
|
||||
env.update(overrides)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
||||
result = announce.main(env)
|
||||
return result, output.getvalue()
|
||||
|
||||
def test_dispatch_only_validates_even_if_event_contains_release(self):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
client.return_value.validate.return_value = metadata()
|
||||
result, output = self.run_main(GITHUB_EVENT_NAME='workflow_dispatch')
|
||||
self.assertEqual(result, 0)
|
||||
client.return_value.validate.assert_called_once()
|
||||
client.return_value.send.assert_not_called()
|
||||
self.assertIn(GUILD_ID, output)
|
||||
self.assertIn(CHANNEL_ID, output)
|
||||
self.assertNotIn('fixture_token', output)
|
||||
|
||||
def test_production_release_sends_once(self):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
client.return_value.send.return_value = MESSAGE_ID
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 0)
|
||||
client.return_value.send.assert_called_once_with(announce.release_payload(event(), '1'))
|
||||
self.assertIn(MESSAGE_ID, output)
|
||||
|
||||
def test_skipped_releases_need_no_secret_or_network(self):
|
||||
for flag in ('draft', 'prerelease'):
|
||||
value = event()
|
||||
value['release'][flag] = True
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
result, _ = self.run_main(value, DISCORD_RELEASE_WEBHOOK_URL='')
|
||||
self.assertEqual(result, 0)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_rerun_never_constructs_client(self):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
result, output = self.run_main(GITHUB_RUN_ATTEMPT='2')
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn('manual reconciliation', output)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_unexpected_event_or_repository_cannot_send(self):
|
||||
for overrides in (
|
||||
{'GITHUB_EVENT_NAME': 'push'},
|
||||
{'GITHUB_EVENT_NAME': 'pull_request'},
|
||||
{'GITHUB_REPOSITORY': 'evil/LangBot'},
|
||||
):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
result, _ = self.run_main(**overrides)
|
||||
self.assertEqual(result, 1)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_missing_secret_fails_clearly_for_send_and_validation(self):
|
||||
for name in ('release', 'workflow_dispatch'):
|
||||
result, output = self.run_main(GITHUB_EVENT_NAME=name, DISCORD_RELEASE_WEBHOOK_URL='')
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn('DISCORD_RELEASE_WEBHOOK_URL is missing', output)
|
||||
|
||||
def test_cli_reads_event_file_and_redacts_invalid_input(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'event.json'
|
||||
value = event()
|
||||
value['release']['tag_name'] = '::error::hostile @everyone'
|
||||
path.write_text(json.dumps(value))
|
||||
env = dict(
|
||||
os.environ,
|
||||
GITHUB_EVENT_NAME='release',
|
||||
GITHUB_EVENT_PATH=str(path),
|
||||
GITHUB_REPOSITORY=REPO,
|
||||
GITHUB_RUN_ATTEMPT='1',
|
||||
DISCORD_RELEASE_WEBHOOK_URL=WEBHOOK,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(__file__).with_name('announce.py'))],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertNotIn('hostile', result.stderr)
|
||||
self.assertNotIn('fixture_token', result.stderr)
|
||||
self.assertNotIn('Traceback', result.stderr)
|
||||
|
||||
def test_unreadable_event_fails_safely(self):
|
||||
result, output = self.run_main(GITHUB_EVENT_PATH='/nonexistent/event.json')
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn('Traceback', output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Build fnOS FPK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
## 发布release的时候会自动构建
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-fnos-fpk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check version
|
||||
id: check_version
|
||||
run: |
|
||||
echo $GITHUB_REF
|
||||
# 如果是tag,则去掉refs/tags/前缀(与其他 release workflow 一致,版本号取 tag 名)
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo "It's a tag"
|
||||
echo "version=$(echo $GITHUB_REF | awk -F '/' '{print $3}')" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# 手动触发(workflow_dispatch):读不到 tag,使用 manifest 内维护的版本
|
||||
echo "It's not a tag"
|
||||
echo "version=$(grep '^version=' packaging/fnos/manifest | cut -d= -f2)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
pip install pillow
|
||||
# fnpack:飞牛官方打包 CLI(静态二进制)
|
||||
curl -fsSL -o /usr/local/bin/fnpack \
|
||||
https://static2.fnnas.com/fnpack/fnpack-1.2.3-linux-amd64
|
||||
chmod +x /usr/local/bin/fnpack
|
||||
|
||||
- name: Build FPK
|
||||
env:
|
||||
FPK_VERSION: ${{ steps.check_version.outputs.version }}
|
||||
run: |
|
||||
bash packaging/fnos/build.sh
|
||||
test -f packaging/fnos/langbot.fpk
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: langbot-${{ steps.check_version.outputs.version }}-fnos
|
||||
path: packaging/fnos/langbot.fpk
|
||||
|
||||
- name: Upload To Release
|
||||
# 仅 release 触发时执行;手动/workflow_dispatch 触发时没有 release,
|
||||
# 且 github.event.release.tag_name 为空(否则 gh release upload 缺参数报错)
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# tag 可能已带 -fnos 后缀(如 v4.10.9-fnos),产物名统一为
|
||||
# langbot-<基础版本>-fnos.fpk,避免出现 -fnos-fnos
|
||||
VER="${{ steps.check_version.outputs.version }}"
|
||||
BASE="${VER#v}"; BASE="${BASE%-fnos}"
|
||||
cp packaging/fnos/langbot.fpk "langbot-${BASE}-fnos.fpk"
|
||||
gh release upload ${{ github.event.release.tag_name }} "langbot-${BASE}-fnos.fpk"
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Discord Release Announcement
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/discord-release.yml'
|
||||
- '.github/discord-release/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/discord-release.yml'
|
||||
- '.github/discord-release/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Offline announcement tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Test helper without secrets or network
|
||||
run: python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v
|
||||
|
||||
validate:
|
||||
name: Validate webhook (GET only, no message)
|
||||
if: github.repository == 'langbot-app/LangBot' && github.event_name == 'workflow_dispatch'
|
||||
needs: tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate incoming webhook and report guild/channel IDs
|
||||
env:
|
||||
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
|
||||
run: python3 .github/discord-release/announce.py
|
||||
|
||||
announce:
|
||||
name: Announce published stable release
|
||||
if: >-
|
||||
github.repository == 'langbot-app/LangBot' &&
|
||||
github.event_name == 'release' && github.event.action == 'published' &&
|
||||
github.event.release.draft == false && github.event.release.prerelease == false
|
||||
needs: tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The helper refuses GITHUB_RUN_ATTEMPT != 1 with recovery guidance.
|
||||
# Never interpolate release data into a shell command.
|
||||
- name: Send once and verify the exact Discord message
|
||||
env:
|
||||
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
|
||||
run: python3 .github/discord-release/announce.py
|
||||
@@ -2,6 +2,11 @@ name: Build and Publish to PyPI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: 'Existing release tag to publish (for example v4.10.11)'
|
||||
required: true
|
||||
type: string
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -11,13 +16,39 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for trusted publishing to PyPI
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release source and version
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
tag = os.environ['RELEASE_TAG']
|
||||
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
|
||||
raise SystemExit('source_ref must be an existing release tag: vX.Y.Z')
|
||||
def revision(ref):
|
||||
return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip()
|
||||
if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'):
|
||||
raise SystemExit('Checked-out commit does not match the release tag')
|
||||
version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
|
||||
if version != tag[1:]:
|
||||
raise SystemExit(f'Package version {version} does not match tag {tag}')
|
||||
print(f'Validated {tag} at {revision("HEAD")} (package {version})')
|
||||
PY
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -26,9 +57,9 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm build
|
||||
# Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles.
|
||||
npm install --include=optional
|
||||
npm run build
|
||||
mkdir -p ../src/langbot/web/dist
|
||||
cp -r dist ../src/langbot/web/
|
||||
|
||||
|
||||
@@ -10,12 +10,16 @@ on:
|
||||
- 'src/langbot/pkg/persistence/**'
|
||||
- 'src/langbot/pkg/entity/persistence/**'
|
||||
- 'tests/integration/persistence/**'
|
||||
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'src/langbot/pkg/persistence/**'
|
||||
- 'src/langbot/pkg/entity/persistence/**'
|
||||
- 'tests/integration/persistence/**'
|
||||
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
|
||||
jobs:
|
||||
test-migrations-sqlite:
|
||||
@@ -80,6 +84,8 @@ jobs:
|
||||
run: >-
|
||||
uv run pytest
|
||||
tests/integration/persistence/test_migrations_postgres.py
|
||||
tests/integration/persistence/test_monitoring_postgres.py
|
||||
tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts
|
||||
tests/integration/persistence/test_pgvector_postgres.py
|
||||
tests/integration/persistence/test_release_migration_postgres.py
|
||||
tests/integration/persistence/test_plugin_identity_migration.py
|
||||
|
||||
+12
@@ -57,3 +57,15 @@ testsdk/
|
||||
|
||||
# Next.js build cache (legacy)
|
||||
web/.next/
|
||||
web/.pnpm-home
|
||||
.tmp
|
||||
Caddyfile
|
||||
|
||||
# fnOS packaging build artifacts (packaging/fnos/build.sh)
|
||||
packaging/fnos/app/langbot/
|
||||
packaging/fnos/app/bin/
|
||||
packaging/fnos/ICON.PNG
|
||||
packaging/fnos/ICON_256.PNG
|
||||
packaging/fnos/app/ui/images/
|
||||
packaging/fnos/app/desktop/images/
|
||||
packaging/fnos/*.fpk
|
||||
|
||||
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
|
||||
## Where to Look
|
||||
|
||||
- Architecture map: `ARCHITECTURE.md`.
|
||||
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
||||
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
|
||||
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
||||
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
|
||||
|
||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Website</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||
|
||||
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||
[→ Learn more about all features](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -89,18 +89,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
**Try it now:** https://demo.langbot.dev/
|
||||
|
||||
- Email: `demo@langbot.app`
|
||||
- Password: `langbot123456`
|
||||
|
||||
_Note: Public demo environment. Do not enter sensitive information._
|
||||
**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +140,7 @@ _Note: Public demo environment. Do not enter sensitive information._
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||
|
||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||
[→ View all integrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官网</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">扩展市场</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -89,17 +89,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## 在线演示
|
||||
|
||||
**立即体验:** https://demo.langbot.dev/
|
||||
- 邮箱:`demo@langbot.app`
|
||||
- 密码:`langbot123456`
|
||||
|
||||
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
||||
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -152,7 +142,7 @@ docker compose --profile all up -d
|
||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||
|
||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
### TTS(语音合成)
|
||||
|
||||
|
||||
+6
-16
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||
|
||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,17 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## Demo en Vivo
|
||||
|
||||
**Pruébelo ahora:** https://demo.langbot.dev/
|
||||
- Correo electrónico: `demo@langbot.app`
|
||||
- Contraseña: `langbot123456`
|
||||
|
||||
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
||||
**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +139,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||
|
||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,17 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## Démo en Ligne
|
||||
|
||||
**Essayez maintenant :** https://demo.langbot.dev/
|
||||
- Email : `demo@langbot.app`
|
||||
- Mot de passe : `langbot123456`
|
||||
|
||||
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
||||
**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +139,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||
|
||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">ホーム</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||
<a href="https://langbot.app/docs/ja/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||
|
||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
|
||||
|
||||
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -88,17 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## ライブデモ
|
||||
|
||||
**今すぐ試す:** https://demo.langbot.dev/
|
||||
- メール: `demo@langbot.app`
|
||||
- パスワード: `langbot123456`
|
||||
|
||||
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
||||
**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +139,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||
|
||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||
|
||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,17 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## 라이브 데모
|
||||
|
||||
**지금 체험:** https://demo.langbot.dev/
|
||||
- 이메일: `demo@langbot.app`
|
||||
- 비밀번호: `langbot123456`
|
||||
|
||||
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
||||
**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +139,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||
|
||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||
|
||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,17 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## Демо
|
||||
|
||||
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Пароль: `langbot123456`
|
||||
|
||||
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
||||
**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +139,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||
|
||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官網</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">外掛市場</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||
|
||||
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -90,17 +90,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## 線上演示
|
||||
|
||||
**立即體驗:** https://demo.langbot.dev/
|
||||
- 信箱:`demo@langbot.app`
|
||||
- 密碼:`langbot123456`
|
||||
|
||||
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
||||
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -165,7 +155,7 @@ docker compose --profile all up -d
|
||||
|-----------|------|
|
||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||
|
||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-16
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Trang chủ</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,17 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
## Demo trực tuyến
|
||||
|
||||
**Thử ngay:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Mật khẩu: `langbot123456`
|
||||
|
||||
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
||||
**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +139,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||
|
||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Docker Compose configuration for LangBot
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kubernetes Deployment for LangBot
|
||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||
#
|
||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
|
||||
#
|
||||
# Usage:
|
||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||
|
||||
@@ -88,6 +88,23 @@ Each endpoint accepts **either**:
|
||||
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
||||
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
|
||||
|
||||
### Inspecting API Key Identity
|
||||
|
||||
`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"instance_uuid": "...",
|
||||
"workspace_uuid": "...",
|
||||
"api_key_id": "...",
|
||||
"permissions": ["..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Model Management
|
||||
|
||||
### List All LLM Models
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# ChatGPT / Codex subscription
|
||||
|
||||
LangBot's **OpenAI Codex** model provider uses **Sign in with ChatGPT** and the account's Codex entitlement. It is separate from the existing OpenAI API-key provider: subscribing to ChatGPT does not supply an OpenAI Platform API key, and API-key billing is unchanged.
|
||||
|
||||
## Connect an account
|
||||
|
||||
1. Open **Models**, choose **Add Provider**, and select **OpenAI Codex**.
|
||||
2. Enter a provider name and choose **Save and sign in**. This saves the provider before authorization, so an interrupted login can be retried from its settings.
|
||||
3. Open the OpenAI authorization link and enter the one-time code displayed in LangBot. Sign in on OpenAI's site, not in LangBot.
|
||||
4. If OpenAI asks you to enable device-code authorization, enable it in your ChatGPT account's security settings, or contact your workspace administrator.
|
||||
5. Keep the LangBot dialog open until it confirms the connection, then finish the form.
|
||||
6. Use the existing **Scan models** or **Add model** controls, test the model, and select it in a pipeline as usual. Only LLM models are supported by this provider.
|
||||
|
||||
The device-code flow also works when LangBot runs remotely or in Docker: the browser does not need to reach a localhost OAuth callback on the server. Serve the LangBot management panel over HTTPS when accessing it remotely.
|
||||
|
||||
The account's model catalog is authoritative. A model listed elsewhere or entered manually is not a guarantee that this account has access. Scan errors are reported rather than replaced with a fabricated available-model list.
|
||||
|
||||
## Reconnect and disconnect
|
||||
|
||||
Open the provider's existing settings to sign in again or disconnect. LangBot refreshes expiring access tokens automatically. A revoked or invalid refresh grant requires another sign-in; transient network failures are not proof that the grant was revoked.
|
||||
|
||||
**Disconnect** removes this provider's locally stored authorization. It does not log the account out of other applications or revoke the account globally. Canceling a pending sign-in is separate from disconnecting an existing account. Removing a provider also removes its authorization; the normal rule that models must be removed first still applies.
|
||||
|
||||
A saved provider can remain disconnected. Scanning or invoking it then returns a sign-in-required error; LangBot does not silently switch to paid API-key billing.
|
||||
|
||||
## Usage and deployment boundary
|
||||
|
||||
Calls consume the connected account's included Codex usage and remain subject to OpenAI's plan limits, model availability, workspace policies, and terms. Token counts recorded by LangBot are request usage, not a measurement of remaining subscription quota or an OpenAI invoice.
|
||||
|
||||
Use this integration for your own authorized account and trusted workflows. Third-party sign-in support is not permission to pool accounts, resell subscription quota, or redistribute one subscription as a shared API service. For a public or commercial multi-user service, use the appropriate OpenAI API or separately authorized enterprise arrangement. The provider remains a Workspace resource in LangBot: consider who can invoke its models before connecting a personal account.
|
||||
|
||||
## Credential handling and API surface
|
||||
|
||||
- OAuth credentials are stored server-side separately from provider API keys. Provider and model reads do not supply OAuth access, refresh, or ID tokens.
|
||||
- Authorization uses a fixed OpenAI origin. The Codex provider does not accept a custom base URL or manually supplied API keys.
|
||||
- Authentication controls require an authenticated LangBot browser user with `provider_secret.manage` in the selected Workspace. Pending attempts are scoped to the Workspace, provider, and initiating user.
|
||||
- Browser storage must not contain OAuth tokens. Treat the server database and its backups as sensitive application data.
|
||||
- MCP and LangBot API keys do not expose the browser-only OAuth controls. Agents may inspect configured providers and models with the existing tools, but a human connects the subscription in the management panel.
|
||||
|
||||
The provider-scoped authentication routes are under `/api/v1/provider/providers/{uuid}/codex`:
|
||||
|
||||
| Method | Suffix | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/status` | Read local connection state without returning credentials |
|
||||
| POST | `/device` | Start device authorization |
|
||||
| POST | `/device/poll` | Poll the initiating user's authorization attempt |
|
||||
| DELETE | `/device/{authorization_id}` | Cancel only that pending attempt |
|
||||
| DELETE | `/auth` | Remove local authorization |
|
||||
|
||||
Use the returned polling interval and expiration time. An expired attempt must be restarted. These routes are not a general-purpose subscription-to-API gateway.
|
||||
|
||||
## References
|
||||
|
||||
- [OpenAI Codex authentication](https://developers.openai.com/codex/auth): ChatGPT versus API-key access and device-code login.
|
||||
- [Hermes Agent providers](https://hermes-agent.nousresearch.com/docs/integrations/providers/): subscription device authentication and refresh recovery.
|
||||
- [OpenClaw OpenAI provider](https://docs.openclaw.ai/providers/openai): subscription and API-key route distinctions.
|
||||
- [New API](https://github.com/QuantumNous/new-api): reference for Codex protocol compatibility; its gateway/account-pooling product model is not adopted here.
|
||||
|
||||
## 中文快速说明
|
||||
|
||||
在「模型」中添加提供商,选择 **OpenAI Codex**,填写名称并点击「保存并登录」。打开 OpenAI 授权页面,输入 LangBot 显示的一次性验证码,完成授权后回到原对话框。随后照常扫描或添加模型、测试模型,并在流水线中选择它。
|
||||
|
||||
无需填写 API Key,也无需为远程服务器配置 localhost 回调。登录中断后可以从该提供商的设置中重试;断开连接只删除 LangBot 中保存的授权。调用消耗所登录账号的 Codex 额度,受账号实际权限和 OpenAI 限制约束,不会自动转用按量付费的 OpenAI API。
|
||||
|
||||
此功能用于自己的授权账号及可信工作流,不应将个人订阅作为面向多个用户转售或共享的 API 服务。提供商仍是 LangBot 工作空间内的资源,连接个人账号前请确认模型的使用范围。
|
||||
@@ -218,8 +218,8 @@ metadata:
|
||||
spec:
|
||||
categories: [popular, global]
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
config:
|
||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||
- { name: callback_url, type: string, required: false, default: "" }
|
||||
|
||||
@@ -243,7 +243,7 @@ For large datasets:
|
||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||
- OceanBase Documentation: https://oceanbase.ai
|
||||
- LangBot Documentation: https://docs.langbot.app
|
||||
- LangBot Documentation: https://langbot.app/docs
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
|
||||
They show the whole loop: signing a request, pushing a message, and receiving
|
||||
multi-part replies on a callback endpoint.
|
||||
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
|
||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||
|
||||
## Files
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||
1→M 的多段回复。
|
||||
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
|
||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
|
||||
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||
a single `<script>` tag.
|
||||
|
||||
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
||||
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||
网站上的那个组件。
|
||||
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
|
||||
LangBot 用户许可协议
|
||||
User License Agreement
|
||||
|
||||
飞牛 fnOS 平台发行版 | 最后更新:2026 年 8 月
|
||||
|
||||
感谢您使用 LangBot!本协议是您(用户)与 LangBot 开源项目(以下简称「LangBot」「我们」)之间,就您在飞牛 fnOS 平台(含飞牛 NAS 设备、飞牛 OS 及其应用中心,以下简称「平台」)上安装、运行、使用 LangBot 应用所订立的合法协议。
|
||||
|
||||
您一旦在飞牛应用中心勾选「我接受许可协议的条款」并继续安装、或以其他方式运行 LangBot,即表示您已阅读、理解并同意本协议的全部内容。如您不同意本协议,请不要安装或使用本软件。
|
||||
|
||||
---
|
||||
|
||||
一、软件性质
|
||||
|
||||
1.1 LangBot 是一款基于 LLM 的多平台智能对话机器人开源软件。主程序源代码按照 Apache License, Version 2.0 公开,您可在遵守开源协议的前提下自由使用、修改与再分发。
|
||||
|
||||
1.2 本发行版系 LangBot 社区为飞牛 fnOS 平台打包构建的自托管移植版本,与飞牛官方、飞牛硬件厂商不存在从属或关联关系。飞牛应用中心提供的分发渠道不构成对软件功能、可用性的任何担保。
|
||||
|
||||
---
|
||||
|
||||
二、使用授权
|
||||
|
||||
2.1 授予您一份有限的、非排他的、不可转让的个人使用许可:您可在一台或多台您合法拥有或管理的 fnOS 设备上安装、运行本软件,用于个人、家庭或组织内部合法用途。
|
||||
|
||||
2.2 您不得:
|
||||
(a) 将本软件用于违反中国大陆地区法律法规或您所在司法管辖区法律的用途;
|
||||
(b) 对机器人账号进行骚扰、诈骗、批量营销、发布违法违规内容等滥用行为;
|
||||
(c) 逆向工程、反编译本软件所包含的第三方二进制(uv 等),但适用法律明确允许或对应开源许可证另作规定的除外;
|
||||
(d) 试图干扰、过载或损害任何由本软件对外提供的服务或其基础设施。
|
||||
|
||||
---
|
||||
|
||||
三、服务可用性与「现状」提供
|
||||
|
||||
3.1 我们努力提供稳定可靠的软件,但**不保证运行无中断或无错误**。软件可能因维护、升级、不可抗力或其他原因暂时不可用。
|
||||
|
||||
3.2 本软件按「现状」「按可用」提供。我们不作任何明示或默示担保,包括但不限于对适销性、特定用途适用性与非侵权性的默示担保。我们不保证:
|
||||
(a) 软件将满足您的具体需求;
|
||||
(b) 运行不间断、及时、安全或无错误;
|
||||
(c) 使用获得的结果准确或可靠;
|
||||
(d) 任何错误都会被修复。
|
||||
|
||||
---
|
||||
|
||||
四、责任限制
|
||||
|
||||
在适用法律允许的最大范围内:
|
||||
(a) 我们不对任何**间接、附带、特殊、惩罚性或后果性损失**承担责任,包括但不限于利润损失、数据丢失、商誉损失、业务中断或其他无形损失,无论是否已被告知该等损害发生的可能性;
|
||||
(b) 无论基于合同、侵权、严格责任或其他任何理论,我们就本软件所引起的所有索赔,向您承担的**累计赔偿总额**不超过 15 美元(或等值当地货币)。
|
||||
|
||||
---
|
||||
|
||||
五、用户责任
|
||||
|
||||
5.1 您对通过本软件发送的所有内容和消息(包括您所配置并运行的机器人发出的消息)承担全部责任。
|
||||
|
||||
5.2 您必须遵守所有适用的法律法规,包括但不限于数据保护、隐私、消费者保护和反垃圾邮件法律。
|
||||
|
||||
5.3 您有责任保管好自己的账号凭据和各类 API Key,并**自行对重要数据进行备份**。我们对因服务中断、账号终止或系统故障等任何原因造成的数据丢失不承担责任。
|
||||
|
||||
5.4 您不得将本软件用于任何非法活动、发送垃圾信息、骚扰他人或侵害他人合法权益。
|
||||
|
||||
5.5 您使用机器人接入任何即时通讯平台(QQ、微信、飞书、钉钉、Telegram 等)前,须自行确认已获得该平台授权并遵守其开发者协议与社区规范;因违规接入导致的账号封禁、平台处罚,由您自行承担。
|
||||
|
||||
---
|
||||
|
||||
六、数据与隐私
|
||||
|
||||
6.1 本自托管版本默认情况下,所有配置、对话记录、知识库与插件数据均保存在您所安装的 fnOS 设备本地共享目录(langbot/data)中,不会被自动上传至除您显式配置的模型/服务提供商以外的任何第三方。您对自己的数据及备份负责。
|
||||
|
||||
6.2 LangBot 默认启用最少量的匿名遥测,用于帮助改进产品。详细政策见官方文档:
|
||||
https://docs.langbot.app/zh/insight/data-collection-policy
|
||||
|
||||
6.3 启用遥测时仅可能发送:查询事件(适配器类型、运行器类型、模型名称、处理耗时、版本号、匿名工作区 UUID、插件/功能使用计数、不含用户内容的错误追踪)、每日一次的工作区心跳(部署概况、资源对象数量)、完全自愿的问卷回答。
|
||||
|
||||
6.4 我们绝对不收集:消息内容、用户名/手机号/平台账号 ID、API 密钥或凭据、IP 地址、文件或媒体内容。
|
||||
|
||||
6.5 关闭方式:进入 LangBot Web 管理界面 → 设置 → Space 遥测,关闭开关;或在配置文件 data/config.yaml 中设置 `space.disable_telemetry: true`。关闭后所有功能照常运行。
|
||||
|
||||
---
|
||||
|
||||
七、不可抗力
|
||||
|
||||
因不可抗力事件导致的履约失败或延迟,我们不承担责任。不可抗力包括但不限于:自然灾害(地震、洪水、飓风等)、战争、恐怖主义或内乱、政府行为或法规、网络攻击(DDoS、勒索软件等)、第三方服务故障(AI 模型提供商、即时通讯平台、飞牛平台运行时等)、电力故障或互联网连接中断、流行病或公共卫生紧急事件。
|
||||
|
||||
---
|
||||
|
||||
八、第三方服务
|
||||
|
||||
本软件可能依赖或集成第三方服务,包括但不限于:即时通讯平台(Telegram、Discord、微信、QQ、Slack 等)、AI 模型提供商(OpenAI、Anthropic、Google、深度求索等)、飞牛平台的应用中心运行时与依赖应用(如 Node.js)。我们不对第三方服务的可用性、准确性、可靠性或安全性负责;使用第三方服务须受其各自条款约束,第三方服务的变更可能不经通知即影响本软件功能。
|
||||
|
||||
---
|
||||
|
||||
九、软件修改与终止
|
||||
|
||||
9.1 我们保留随时修改、暂停或终止软件或其中任何部分的权利,无论是否事先通知。
|
||||
9.2 我们保留随时修改本协议的权利。对重大变更我们会尽力在项目主页公告,继续使用软件即视为接受修订后的协议。
|
||||
9.3 您可以在飞牛应用中心卸载本软件。卸载时向导会询问是否保留数据,您可自主选择。终止后您继续使用软件的权利立即终止,我们无义务保留您的数据。
|
||||
|
||||
---
|
||||
|
||||
十、赔偿
|
||||
|
||||
您同意赔偿、抗辩并使 LangBot 团队及其关联贡献者、管理人员、代理人、员工免受任何及所有因以下事项引起或与之相关的索赔、损失、损害、责任、成本和费用(包括合理的律师费):
|
||||
(a) 您对软件的使用;
|
||||
(b) 您违反本协议;
|
||||
(c) 您违反任何适用的法律或法规;
|
||||
(d) 您侵犯任何第三方权利;
|
||||
(e) 您或您所配置的机器人通过本软件传输的内容。
|
||||
|
||||
---
|
||||
|
||||
十一、知识产权
|
||||
|
||||
11.1 本软件及其设计、代码、文档和品牌归 LangBot 项目所有并受知识产权法保护。
|
||||
11.2 使用本软件并不授予您对软件的任何所有权。
|
||||
11.3 您保留通过本软件创建和传输内容的所有权。
|
||||
|
||||
---
|
||||
|
||||
十二、争议解决
|
||||
|
||||
因本协议引起或与之相关的任何争议,应首先通过友好协商解决。协商在 30 日内未达成一致的,任何一方均可依适用法律规定向有管辖权的法院提起诉讼。
|
||||
|
||||
---
|
||||
|
||||
十三、可分割性
|
||||
|
||||
如本协议的任何条款被认定为不可执行或无效,该条款应在最小必要范围内予以限制或剔除,其余条款继续完全有效。
|
||||
|
||||
---
|
||||
|
||||
十四、完整协议
|
||||
|
||||
本协议连同我们的数据收集政策(https://docs.langbot.app/zh/insight/data-collection-policy)构成您与我们之间关于本软件的完整协议,并取代所有先前的协议与谅解。
|
||||
|
||||
---
|
||||
|
||||
附录:开源许可证声明
|
||||
LangBot 主程序代码依照 Apache License 2.0 发布。详细条款见:
|
||||
https://github.com/langbot-app/LangBot/blob/master/LICENSE
|
||||
或 LangBot 源码包内的 LICENSE 文件。
|
||||
|
||||
数据收集政策:https://docs.langbot.app/zh/insight/data-collection-policy
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# LangBot fnOS Packaging
|
||||
|
||||
This directory packages LangBot as a `.fpk` app for the fnOS App Store. It is a native deployment: no Docker involved — uv creates a Python virtual environment directly on the NAS, and Node.js v22 from the fnOS App Store provides the Box sandbox and npx MCP capabilities.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
packaging/fnos/
|
||||
├── manifest # App metadata (appname/version/port/dependency declarations)
|
||||
├── build.sh # One-shot build script (shared by local and CI)
|
||||
├── LICENSE
|
||||
├── config/
|
||||
│ ├── privilege # Privilege config (run-as: root)
|
||||
│ └── resource # Persistent data share declaration (langbot/data)
|
||||
├── cmd/ # Lifecycle scripts (fnOS invokes them with TRIM_* env vars)
|
||||
│ ├── main # Service start/stop manager (start/stop/status, owns PID/log)
|
||||
│ ├── install_init # Pre-install hook
|
||||
│ ├── install_callback # Post-install hook: create venv, uv sync deps, seed config.yaml port
|
||||
│ ├── upgrade_init # Pre-upgrade hook
|
||||
│ ├── upgrade_callback # Post-upgrade hook
|
||||
│ ├── uninstall_init # Pre-uninstall hook
|
||||
│ ├── uninstall_callback # Post-uninstall hook (keeps data per wizard choice)
|
||||
│ ├── config_init # Pre-config-change hook
|
||||
│ └── config_callback # Post-config-change hook (apply new port etc.)
|
||||
├── wizard/ # Install wizards (JSON forms; values passed as wizard_* env vars)
|
||||
│ ├── install # On install: Node version, web port, deployment-time notice
|
||||
│ ├── upgrade # On upgrade: Node version confirmation
|
||||
│ └── uninstall # On uninstall: whether to keep data
|
||||
├── app/
|
||||
│ ├── ui/config # Desktop entry declaration (${wizard_port} placeholder, substituted by fnOS at install)
|
||||
│ ├── desktop/langbot.main.url
|
||||
│ ├── langbot/ # [generated] repo source synced via rsync (includes web/dist)
|
||||
│ └── bin/ # [generated] offline uv binaries (x86_64/aarch64)
|
||||
├── ICON.PNG / ICON_256.PNG # [generated] derived from res/logo-blue.png
|
||||
└── langbot.fpk # [generated] final artifact
|
||||
```
|
||||
|
||||
Paths marked `[generated]` are produced by `build.sh`, ignored via `.gitignore`; everything else is a git-tracked source file.
|
||||
|
||||
## Building
|
||||
|
||||
### Locally
|
||||
|
||||
```bash
|
||||
bash packaging/fnos/build.sh
|
||||
```
|
||||
|
||||
Dependencies: python3 + Pillow, node + npm (or pnpm), fnpack (official fnOS packaging CLI, download from https://developer.fnnas.com/docs/cli/fnpack).
|
||||
|
||||
The version comes from `version=` maintained in the manifest; it can also be injected: `FPK_VERSION=4.10.10-1 bash packaging/fnos/build.sh`.
|
||||
|
||||
### CI
|
||||
|
||||
[`.github/workflows/build-fnos-fpk.yaml`](../../.github/workflows/build-fnos-fpk.yaml) triggers automatically on Release publication and uploads `langbot-<version>-fnos.fpk` to the Release; it can also be triggered manually via workflow_dispatch.
|
||||
|
||||
Version sources (consistent with the other release workflows):
|
||||
|
||||
| Trigger | Version source |
|
||||
|---|---|
|
||||
| Release/tag auto build | Tag name (`v4.10.10-1` → in-package `4.10.10-1`; build.sh strips the `v` prefix on injection) |
|
||||
| Manual workflow_dispatch / local build | Version maintained in manifest |
|
||||
|
||||
## Final Artifact
|
||||
|
||||
`langbot.fpk` (gzip + tar archive), containing:
|
||||
|
||||
- `manifest` — realigned and appended with a `checksum` field by fnpack
|
||||
- `app.tgz` — app payload (source, web/dist, uv binaries, entry configs)
|
||||
- `cmd/`, `config/`, `wizard/` — lifecycle scripts and wizards
|
||||
- `ICON.PNG`, `ICON_256.PNG`, `LICENSE`
|
||||
|
||||
The first startup after installation takes about 5-10 minutes to finish dependency deployment (uv venv + sync); after that the web admin UI is reachable via the desktop icon or `http://<NAS-IP>:<port>` (default port 5300).
|
||||
|
||||
## References
|
||||
|
||||
- fnOS developer docs: https://developer.fnnas.com/
|
||||
- fnOS app wizard: https://developer.fnnas.com/docs/core-concepts/wizard/
|
||||
- fnpack CLI: https://developer.fnnas.com/docs/cli/fnpack
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "LangBot",
|
||||
"icon": "images/icon-256.png",
|
||||
"type": "url",
|
||||
"protocol": "http",
|
||||
"port": "${wizard_port}",
|
||||
"url": "/",
|
||||
"allUsers": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
".url": {
|
||||
"langbot.main": {
|
||||
"title": "LangBot",
|
||||
"icon": "images/icon-{0}.png",
|
||||
"type": "url",
|
||||
"protocol": "http",
|
||||
"port": "${wizard_port}",
|
||||
"url": "/",
|
||||
"allUsers": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# packaging/fnos/build.sh - Build LangBot fnOS FPK package (in-repo version)
|
||||
# 在 LangBot 仓库内直接打包飞牛 fnOS 应用
|
||||
# Usage:
|
||||
# bash packaging/fnos/build.sh # 版本取 manifest 中 version=
|
||||
# FPK_VERSION=4.10.9 bash packaging/fnos/build.sh # 注入版本(CI 用 release tag)
|
||||
# Dependencies: python3+Pillow, node+npm, fnpack
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # LangBot 仓库根
|
||||
FPK_DIR="${SCRIPT_DIR}"
|
||||
|
||||
echo "==> LangBot fnOS FPK builder (in-repo)"
|
||||
echo " Source root: ${SRC_ROOT}"
|
||||
echo " FPK dir: ${FPK_DIR}"
|
||||
|
||||
# --- 0. Inject version (release tag) ---
|
||||
if [ -n "${FPK_VERSION:-}" ]; then
|
||||
sed -i "s/^version=.*/version=${FPK_VERSION#v}/" "${FPK_DIR}/manifest"
|
||||
else
|
||||
# 无注入版本时自动跟进仓库主版本(pyproject.toml)
|
||||
PY_VER=$(grep -m1 '^version = ' "${SRC_ROOT}/pyproject.toml" | cut -d'"' -f2)
|
||||
if [ -n "${PY_VER}" ]; then
|
||||
sed -i "s/^version=.*/version=${PY_VER}/" "${FPK_DIR}/manifest"
|
||||
fi
|
||||
fi
|
||||
MANIFEST_VER=$(grep '^version=' "${FPK_DIR}/manifest" | cut -d= -f2)
|
||||
echo " FPK version: ${MANIFEST_VER}"
|
||||
|
||||
# --- 1. Build frontend ---
|
||||
echo "[1/5] Building frontend (web/dist)..."
|
||||
cd "${SRC_ROOT}/web"
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
pnpm build
|
||||
else
|
||||
npm install
|
||||
npx vite build
|
||||
fi
|
||||
[ -d dist ] || { echo "ERROR: web/dist missing" >&2; exit 1; }
|
||||
echo " Frontend built"
|
||||
|
||||
# --- 2. Sync source into packaging/fnos/app/langbot/ ---
|
||||
echo "[2/5] Syncing source to app/langbot/..."
|
||||
rm -rf "${FPK_DIR}/app/langbot"
|
||||
mkdir -p "${FPK_DIR}/app/langbot"
|
||||
cd "${SRC_ROOT}"
|
||||
rsync -a \
|
||||
--exclude='.git' \
|
||||
--exclude='.venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='web/node_modules' \
|
||||
--exclude='web/.vite' \
|
||||
--exclude='tests' \
|
||||
--exclude='packaging' \
|
||||
--exclude='.pytest_cache' \
|
||||
--exclude='.mypy_cache' \
|
||||
--exclude='.ruff_cache' \
|
||||
--exclude='data' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.dockerignore' \
|
||||
--exclude='Dockerfile' \
|
||||
--exclude='docker/' \
|
||||
--exclude='kubernetes.yaml' \
|
||||
--exclude='.github/' \
|
||||
--exclude='docs/' \
|
||||
--exclude='examples/' \
|
||||
--exclude='res/' \
|
||||
./ "${FPK_DIR}/app/langbot/"
|
||||
|
||||
[ -d "${FPK_DIR}/app/langbot/web/dist" ] || { echo "ERROR: web/dist missing after rsync!" >&2; exit 1; }
|
||||
echo " Source synced ($(du -sh "${FPK_DIR}/app/langbot" | cut -f1))"
|
||||
|
||||
# --- 2.5 Download bundled uv binaries (offline install on NAS) ---
|
||||
echo "[2.5/5] Downloading bundled uv binaries..."
|
||||
UV_VERSION="0.12.9"
|
||||
mkdir -p "${FPK_DIR}/app/bin"
|
||||
for arch in x86_64 aarch64; do
|
||||
out="${FPK_DIR}/app/bin/uv-${arch}"
|
||||
if [ -x "${out}" ]; then
|
||||
echo " uv-${arch} already present, skip"
|
||||
continue
|
||||
fi
|
||||
tmp="$(mktemp -d)"
|
||||
if curl -sSL -o "${tmp}/uv.tar.gz" \
|
||||
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${arch}-unknown-linux-gnu.tar.gz" \
|
||||
&& tar xzf "${tmp}/uv.tar.gz" -C "${tmp}" \
|
||||
&& cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}"; then
|
||||
chmod +x "${out}"
|
||||
echo " uv-${arch} downloaded (${UV_VERSION})"
|
||||
else
|
||||
echo " WARNING: failed to download uv for ${arch}, install will fall back to online install" >&2
|
||||
fi
|
||||
rm -rf "${tmp}"
|
||||
done
|
||||
|
||||
# --- 3. Regenerate icons from res/logo-blue.png ---
|
||||
echo "[3/5] Generating icons from res/logo-blue.png..."
|
||||
export LOGO_SRC="${SRC_ROOT}/res/logo-blue.png"
|
||||
export OUT_DIR="${FPK_DIR}"
|
||||
python3 << 'PYEOF'
|
||||
from PIL import Image
|
||||
import os, sys
|
||||
|
||||
src = os.environ.get("LOGO_SRC")
|
||||
out_dir = os.environ.get("OUT_DIR")
|
||||
if not src or not out_dir:
|
||||
print("ERROR: LOGO_SRC or OUT_DIR not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
print(f"ERROR: logo source not found: {src}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
img = Image.open(src).convert("RGBA")
|
||||
|
||||
for size, name in [(64, "ICON.PNG"), (256, "ICON_256.PNG")]:
|
||||
img.resize((size, size), Image.LANCZOS).save(os.path.join(out_dir, name))
|
||||
|
||||
ui_dir = os.path.join(out_dir, "app/ui/images")
|
||||
os.makedirs(ui_dir, exist_ok=True)
|
||||
for size in [64, 256]:
|
||||
img.resize((size, size), Image.LANCZOS).save(os.path.join(ui_dir, f"icon-{size}.png"))
|
||||
|
||||
desktop_dir = os.path.join(out_dir, "app/desktop/images")
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
for size in [64, 256]:
|
||||
img.resize((size, size), Image.LANCZOS).save(os.path.join(desktop_dir, f"icon-{size}.png"))
|
||||
|
||||
print(" Icons generated")
|
||||
PYEOF
|
||||
|
||||
# --- 4. Validate structure ---
|
||||
echo "[4/5] Validating FPK structure..."
|
||||
ERRORS=0
|
||||
[ -f "${FPK_DIR}/manifest" ] || { echo " MISSING: manifest"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/config/privilege" ] || { echo " MISSING: config/privilege"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/config/resource" ] || { echo " MISSING: config/resource"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/ICON.PNG" ] || { echo " MISSING: ICON.PNG"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/ICON_256.PNG" ] || { echo " MISSING: ICON_256.PNG"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/ui/config" ] || { echo " MISSING: app/ui/config"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/ui/images/icon-64.png" ] || { echo " MISSING: app/ui/images/icon-64.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/ui/images/icon-256.png" ] || { echo " MISSING: app/ui/images/icon-256.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/desktop/langbot.main.url" ] || { echo " MISSING: app/desktop/langbot.main.url"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/desktop/images/icon-64.png" ] || { echo " MISSING: app/desktop/images/icon-64.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/desktop/images/icon-256.png" ] || { echo " MISSING: app/desktop/images/icon-256.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -d "${FPK_DIR}/cmd" ] || { echo " MISSING: cmd/"; ERRORS=$((ERRORS+1)); }
|
||||
[ -d "${FPK_DIR}/wizard" ] || { echo " MISSING: wizard/"; ERRORS=$((ERRORS+1)); }
|
||||
|
||||
for script in "${FPK_DIR}/cmd/"*; do
|
||||
[ -x "${script}" ] || { echo " NOT EXECUTABLE: cmd/$(basename "$script")"; ERRORS=$((ERRORS+1)); }
|
||||
done
|
||||
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "FAILED: ${ERRORS} validation errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Structure OK"
|
||||
|
||||
# --- 5. Build FPK ---
|
||||
echo "[5/5] Building .fpk..."
|
||||
if ! command -v fnpack >/dev/null 2>&1; then
|
||||
echo "ERROR: fnpack not found in PATH." >&2
|
||||
echo " Download from https://developer.fnnas.com/docs/cli/fnpack" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${FPK_DIR}"
|
||||
fnpack build
|
||||
FPK_FILE=$(ls -t *.fpk 2>/dev/null | head -1)
|
||||
if [ -n "${FPK_FILE}" ]; then
|
||||
echo ""
|
||||
echo "==> Done! FPK: ${FPK_DIR}/${FPK_FILE}"
|
||||
echo " Size: $(du -sh "${FPK_FILE}" | cut -f1)"
|
||||
else
|
||||
echo "WARNING: fnpack finished but no .fpk found in ${FPK_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# cmd/config_callback - post-config hook
|
||||
# Applies wizard-provided settings (e.g. Node.js version) that cmd/main reads.
|
||||
# Nothing to persist currently; cmd/main reads wizard_node_version at runtime.
|
||||
|
||||
exit 0
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# cmd/config_init - pre-config hook
|
||||
|
||||
exit 0
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
# cmd/install_callback - post-install hook
|
||||
# Prefers bundled uv binary, creates Python venv, syncs deps, verifies dist.
|
||||
# Also validates the user-selected Node.js version is actually installed.
|
||||
|
||||
APP_DIR="${TRIM_APPDEST}/langbot"
|
||||
|
||||
# --- Resolve data directory ---
|
||||
# LangBot loads config CWD-relative (data/config.yaml); cmd/main replaces
|
||||
# APP_DIR/data with a symlink to this persistent dir on every start.
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
if [ -z "${DATA_DIR}" ]; then
|
||||
DATA_DIR="${TRIM_PKGVAR}/data"
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}" || {
|
||||
echo "App directory missing after install" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure data directory exists ---
|
||||
mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" 2>/dev/null || true
|
||||
|
||||
# --- Pre-seed config.yaml with the user-selected web port ---
|
||||
# Data root points at the persistent share (LANGBOT_DATA_ROOT is exported by
|
||||
# cmd/main at start), so this config survives app upgrades.
|
||||
# LangBot copies templates/config.yaml there on first boot only if missing,
|
||||
# so we seed it ourselves with the chosen port.
|
||||
PORT="${wizard_port:-5300}"
|
||||
case "${PORT}" in
|
||||
''|*[!0-9]*) PORT="5300" ;;
|
||||
esac
|
||||
TEMPLATE_FILE="${APP_DIR}/src/langbot/templates/config.yaml"
|
||||
|
||||
_patch_config() {
|
||||
local cfg_dir="$1"
|
||||
local cfg_file="${cfg_dir}/config.yaml"
|
||||
mkdir -p "${cfg_dir}" 2>/dev/null || true
|
||||
if [ ! -f "${cfg_file}" ] && [ -f "${TEMPLATE_FILE}" ]; then
|
||||
cp "${TEMPLATE_FILE}" "${cfg_file}"
|
||||
fi
|
||||
if [ -f "${cfg_file}" ]; then
|
||||
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${PORT}/" "${cfg_file}"
|
||||
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${PORT}'#" "${cfg_file}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Seed the persistent dir AND the CWD-relative APP_DIR/data (cmd/main merges
|
||||
# the latter into the persistent dir via symlink on first start, so the port
|
||||
# survives regardless of which path ends up being read).
|
||||
_patch_config "${DATA_DIR}"
|
||||
if [ "${APP_DIR}/data" != "${DATA_DIR}" ]; then
|
||||
_patch_config "${APP_DIR}/data"
|
||||
fi
|
||||
|
||||
# --- Fallback patch for desktop entry port ---
|
||||
# fnOS natively substitutes ${wizard_port} in ui/config at install time.
|
||||
# This only kicks in if the placeholder somehow survived (e.g. CLI install).
|
||||
UI_CONFIG="${TRIM_APPDEST}/ui/config"
|
||||
if [ -f "${UI_CONFIG}" ] && grep -q 'wizard_port\|{port}' "${UI_CONFIG}"; then
|
||||
sed -i "s/\${wizard_port}/${PORT}/g; s/{port}/${PORT}/g" "${UI_CONFIG}"
|
||||
fi
|
||||
|
||||
# --- Validate user-selected Node.js version is installed ---
|
||||
NODE_VERSION="${wizard_node_version:-22}"
|
||||
if [ ! -d "/var/apps/nodejs_v${NODE_VERSION}" ]; then
|
||||
echo "Node.js v${NODE_VERSION} 未安装:请先在应用中心安装 nodejs_v${NODE_VERSION},再重新安装本应用。" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Python check ---
|
||||
PYTHON_BIN="python3"
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
fi
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "Python not found on this system" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PY_VER=$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
|
||||
if [ -z "${PY_VER}" ]; then
|
||||
echo "Python 3.11+ required but not found on this system" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
PY_MAJOR=$(echo "${PY_VER}" | cut -d. -f1)
|
||||
PY_MINOR=$(echo "${PY_VER}" | cut -d. -f2)
|
||||
if [ "${PY_MAJOR}" -lt 3 ] || { [ "${PY_MAJOR}" -eq 3 ] && [ "${PY_MINOR}" -lt 11 ]; }; then
|
||||
echo "Python 3.11+ required, found ${PY_VER}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Resolve uv: bundled binary first, then online fallbacks ---
|
||||
UV_BIN=""
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-x86_64" ;;
|
||||
aarch64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-aarch64" ;;
|
||||
*) BUNDLED_UV="" ;;
|
||||
esac
|
||||
|
||||
if [ -n "${BUNDLED_UV}" ] && [ -x "${BUNDLED_UV}" ]; then
|
||||
mkdir -p "${TRIM_PKGVAR}/bin"
|
||||
cp "${BUNDLED_UV}" "${TRIM_PKGVAR}/bin/uv" && chmod +x "${TRIM_PKGVAR}/bin/uv"
|
||||
UV_BIN="${TRIM_PKGVAR}/bin/uv"
|
||||
fi
|
||||
|
||||
if [ -z "${UV_BIN}" ] && command -v uv >/dev/null 2>&1; then
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
|
||||
if [ -z "${UV_BIN}" ]; then
|
||||
"${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \
|
||||
"${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || true
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
UV_BIN="uv"
|
||||
elif [ -x "${HOME}/.local/bin/uv" ]; then
|
||||
UV_BIN="${HOME}/.local/bin/uv"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${UV_BIN}" ]; then
|
||||
echo "无法获取 uv:内置二进制缺失且在线安装失败。请检查网络后重新安装。" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Create venv via uv ---
|
||||
if [ ! -d ".venv" ]; then
|
||||
"${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || {
|
||||
echo "Failed to create Python virtual environment via uv" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Sync dependencies ---
|
||||
"${UV_BIN}" sync --extra seekdb || {
|
||||
echo "Dependency sync failed. Check network connectivity." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Verify frontend dist ---
|
||||
if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then
|
||||
echo "Frontend dist missing! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# cmd/install_init - pre-install hook
|
||||
# Nothing special to do before extraction.
|
||||
|
||||
exit 0
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/bin/bash
|
||||
# cmd/main - LangBot lifecycle manager for fnOS
|
||||
# Handles start / stop / status via standalone-runtime Python process.
|
||||
# Node.js path is injected into PATH so Box sandbox npx MCP servers can run.
|
||||
|
||||
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
||||
APP_DIR="${TRIM_APPDEST}/langbot"
|
||||
LOG_FILE="${TRIM_PKGVAR}/langbot.log"
|
||||
|
||||
# --- Locate fnOS Node.js bin path ---
|
||||
# fnOS appname-based path: /var/apps/nodejs_vXX/target/bin
|
||||
# This is a stable symlink regardless of which volume the app is on.
|
||||
NODE_VERSION="${wizard_node_version:-22}"
|
||||
NODE_BIN_DIR="/var/apps/nodejs_v${NODE_VERSION}/target/bin"
|
||||
|
||||
if [ -d "${NODE_BIN_DIR}" ]; then
|
||||
export PATH="${NODE_BIN_DIR}:${PATH}"
|
||||
fi
|
||||
|
||||
# --- Persistent data root ---
|
||||
# LangBot loads data/config.yaml CWD-RELATIVE (see core/stages/load_config.py:
|
||||
# load_yaml_config('data/config.yaml', ...)) and resolves its data root to
|
||||
# <CWD>/data in source-install mode — it does NOT honour LANGBOT_DATA_ROOT for
|
||||
# config.yaml. So the real fix is the symlink below: APP_DIR/data -> DATA_DIR.
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
if [ -z "${DATA_DIR}" ]; then
|
||||
DATA_DIR="${TRIM_PKGVAR}/data"
|
||||
fi
|
||||
export LANGBOT_DATA_ROOT="${DATA_DIR}"
|
||||
mkdir -p "${DATA_DIR}" 2>/dev/null || true
|
||||
|
||||
# --- Locate Python ---
|
||||
PYTHON_BIN="python3"
|
||||
! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python"
|
||||
|
||||
# --- Locate uv ---
|
||||
# install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv
|
||||
UV_BIN="${TRIM_PKGVAR}/bin/uv"
|
||||
if [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1; then
|
||||
UV_BIN="${HOME}/.local/bin/uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="${HOME}/.cargo/bin/uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="${APP_DIR}/.venv/bin/uv"
|
||||
fi
|
||||
|
||||
case $1 in
|
||||
start)
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
|
||||
if [ ! -d "${APP_DIR}" ]; then
|
||||
echo "LangBot app directory missing: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}" || {
|
||||
echo "Cannot enter app directory: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Unify data location: APP_DIR/data -> symlink to persistent DATA_DIR ---
|
||||
# LangBot reads config CWD-relative (data/config.yaml), so without this a
|
||||
# fresh data/ with a default 5300 config gets recreated inside target/ on
|
||||
# every install/upgrade. The symlink keeps everything on the persistent
|
||||
# share; it is recreated here on each start (upgrades wipe target/).
|
||||
APP_DATA="${APP_DIR}/data"
|
||||
if [ -L "${APP_DATA}" ]; then
|
||||
# already a symlink; re-point if the persistent dir changed
|
||||
[ "$(readlink "${APP_DATA}")" != "${DATA_DIR}" ] && ln -sfn "${DATA_DIR}" "${APP_DATA}"
|
||||
elif [ -d "${APP_DATA}" ]; then
|
||||
# legacy real dir (created by LangBot before this fix): merge into the
|
||||
# persistent dir without overwriting newer files already there
|
||||
mkdir -p "${DATA_DIR}"
|
||||
cp -an "${APP_DATA}/." "${DATA_DIR}/" 2>/dev/null || cp -a "${APP_DATA}/." "${DATA_DIR}/"
|
||||
rm -rf "${APP_DATA}"
|
||||
ln -s "${DATA_DIR}" "${APP_DATA}"
|
||||
else
|
||||
ln -s "${DATA_DIR}" "${APP_DATA}"
|
||||
fi
|
||||
|
||||
# Ensure LangBot's actual listen port always matches what fnOS shows in
|
||||
# "应用设置 → 访问端口" (which is the single source of truth from the user's
|
||||
# POV). Two sources, checked in priority order:
|
||||
# 1. ${wizard_port} — only set during install/upgrade callbacks (not on
|
||||
# normal `start`; kept for completeness).
|
||||
# 2. target/ui/config — read the "port" field written by fnOS after
|
||||
# ${wizard_port} substitution AND any later edit the user made via
|
||||
# "应用设置 → 自定义 URL" pencil button.
|
||||
# Without this: user picks 5303 in wizard, LangBot still listens on its
|
||||
# default 5300, desktop shortcut hits 5303 → connection refused.
|
||||
CONFIG_FILE="${DATA_DIR}/config.yaml"
|
||||
_patch_port() {
|
||||
local _port="$1"
|
||||
case "${_port}" in
|
||||
''|*[!0-9]*) return ;;
|
||||
esac
|
||||
if [ ! -f "${CONFIG_FILE}" ]; then
|
||||
local _tmpl="${APP_DIR}/src/langbot/templates/config.yaml"
|
||||
[ -f "${_tmpl}" ] && cp "${_tmpl}" "${CONFIG_FILE}"
|
||||
fi
|
||||
if [ -f "${CONFIG_FILE}" ]; then
|
||||
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${_port}/" "${CONFIG_FILE}"
|
||||
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${_port}'#" "${CONFIG_FILE}"
|
||||
fi
|
||||
}
|
||||
if [ -n "${wizard_port:-}" ]; then
|
||||
_patch_port "${wizard_port}"
|
||||
fi
|
||||
if [ -f "${TRIM_APPDEST}/ui/config" ]; then
|
||||
_port_from_ui=$(python3 -c 'import json,sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
for _name, _entry in (d.get(".url") or {}).items():
|
||||
p = _entry.get("port")
|
||||
if isinstance(p, (int, float)):
|
||||
print(int(p))
|
||||
elif isinstance(p, str) and p.isdigit():
|
||||
print(int(p))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
' "${TRIM_APPDEST}/ui/config" 2>/dev/null)
|
||||
if [ -n "${_port_from_ui}" ]; then
|
||||
_patch_port "${_port_from_ui}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Native deployment: no --standalone-runtime flag, LangBot spawns the
|
||||
# plugin runtime as a stdio subprocess (same as official `uv run main.py`).
|
||||
# (--standalone-runtime would require an external runtime at
|
||||
# ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.)
|
||||
# --standalone-box omitted: Box sandbox defaults off, users enable via Web UI
|
||||
nohup "${UV_BIN}" run --no-sync main.py \
|
||||
> "${LOG_FILE}" 2>&1 &
|
||||
echo $! > "${PID_FILE}"
|
||||
|
||||
sleep 3
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "LangBot failed to start. Check ${LOG_FILE}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
stop)
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ]; then
|
||||
kill "${PID}" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "${PID}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "${PID}" 2>/dev/null
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
status)
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
exit 3
|
||||
;;
|
||||
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# cmd/uninstall_callback - post-uninstall hook
|
||||
# The system preserves var/ and shares/ by default. Honor the user's
|
||||
# wizard_keep_data choice: delete data only when explicitly requested.
|
||||
|
||||
if [ "${wizard_keep_data:-yes}" = "no" ]; then
|
||||
# 应用运行数据(pid、日志等)
|
||||
if [ -n "${TRIM_PKGVAR}" ]; then
|
||||
rm -rf "${TRIM_PKGVAR:?}"/langbot.pid \
|
||||
"${TRIM_PKGVAR:?}"/langbot.log \
|
||||
"${TRIM_PKGVAR:?}"/bin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 共享数据目录(langbot/data)
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
if [ -n "${DATA_DIR}" ]; then
|
||||
rm -rf "${DATA_DIR:?}" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# cmd/uninstall_init - pre-uninstall hook
|
||||
# Stop LangBot before files are removed.
|
||||
|
||||
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
||||
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
kill "${PID}" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "${PID}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "${PID}" 2>/dev/null
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# cmd/upgrade_callback - post-upgrade hook
|
||||
# Re-sync dependencies after code replacement using uv.
|
||||
|
||||
APP_DIR="${TRIM_APPDEST}/langbot"
|
||||
|
||||
# Persistent data root (must match cmd/main)
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
[ -z "${DATA_DIR}" ] && DATA_DIR="${TRIM_PKGVAR}/data"
|
||||
|
||||
# Apply port from upgrade wizard (config persists across upgrades; this
|
||||
# only rewrites it when the user changed the value in the upgrade wizard)
|
||||
CONFIG_FILE="${DATA_DIR}/config.yaml"
|
||||
if [ -n "${wizard_port:-}" ] && [ -f "${CONFIG_FILE}" ]; then
|
||||
case "${wizard_port}" in
|
||||
''|*[!0-9]*) ;;
|
||||
*)
|
||||
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${wizard_port}/" "${CONFIG_FILE}"
|
||||
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${wizard_port}'#" "${CONFIG_FILE}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}" || {
|
||||
echo "App directory missing after upgrade" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find uv (bundled first, then PATH / ~/.local/bin / ~/.cargo/bin)
|
||||
UV_BIN="${TRIM_PKGVAR}/bin/uv"
|
||||
if [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1; then
|
||||
UV_BIN="${HOME}/.local/bin/uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="${HOME}/.cargo/bin/uv"
|
||||
fi
|
||||
|
||||
PYTHON_BIN="python3"
|
||||
! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python"
|
||||
|
||||
# Re-sync deps
|
||||
if [ -d ".venv" ]; then
|
||||
"${UV_BIN}" sync --extra seekdb 2>/dev/null || {
|
||||
echo "Dependency sync failed after upgrade" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
# Venv was lost, recreate via uv
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
"${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \
|
||||
"${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || {
|
||||
echo "Failed to install uv" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
"${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || {
|
||||
echo "Failed to recreate virtual environment" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
"${UV_BIN}" sync --extra seekdb || {
|
||||
echo "Dependency sync failed" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Verify frontend dist still present
|
||||
if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then
|
||||
echo "Frontend dist missing after upgrade! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# cmd/upgrade_init - pre-upgrade hook
|
||||
# Stop the running LangBot process before files are replaced.
|
||||
|
||||
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
||||
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
kill "${PID}" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "${PID}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "${PID}" 2>/dev/null
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaults": {
|
||||
"run-as": "root"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"data-share": {
|
||||
"shares": [
|
||||
{
|
||||
"name": "langbot/data"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
appname=langbot
|
||||
version=4.10.10
|
||||
display_name=LangBot
|
||||
desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。
|
||||
platform=all
|
||||
source=thirdparty
|
||||
maintainer=LangBot
|
||||
maintainer_url=https://langbot.app
|
||||
service_port=5300
|
||||
checkport=true
|
||||
os_min_version=0.9.0
|
||||
desktop_uidir=ui
|
||||
desktop_applaunchname=langbot.main
|
||||
ctl_stop=true
|
||||
install_dep_apps=nodejs_v22
|
||||
changelog=飞牛 fnOS 增强版首版:原生 Python 部署,依赖 Node.js v22 以启用 Box 沙箱 + npx MCP 能力
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "运行环境",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。请先在应用中心安装 Node.js v22。"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"field": "wizard_node_version",
|
||||
"label": "Node.js 版本",
|
||||
"initValue": "22",
|
||||
"options": [
|
||||
{ "label": "Node.js v22 (推荐, LTS)", "value": "22" },
|
||||
{ "label": "Node.js v24", "value": "24" },
|
||||
{ "label": "Node.js v20", "value": "20" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stepTitle": "访问配置",
|
||||
"items": [
|
||||
{
|
||||
"type": "text",
|
||||
"field": "wizard_port",
|
||||
"label": "Web 访问端口",
|
||||
"initValue": "5300",
|
||||
"rules": [
|
||||
{ "required": true, "message": "请输入访问端口" },
|
||||
{ "pattern": "^[0-9]+$", "message": "端口只能是数字" },
|
||||
{ "min": 1, "max": 5, "message": "端口号长度不正确" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stepTitle": "安装说明",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "安装完成后,LangBot 首次启动约需 5-10 分钟完成依赖部署与初始化,部署完成后即可打开网页端使用。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "数据保留",
|
||||
"items": [
|
||||
{
|
||||
"type": "radio",
|
||||
"field": "wizard_keep_data",
|
||||
"label": "是否保留 LangBot 数据(插件、配置、日志)",
|
||||
"initValue": "yes",
|
||||
"options": [
|
||||
{ "label": "保留数据(重新安装后可继续使用)", "value": "yes" },
|
||||
{ "label": "彻底删除全部数据", "value": "no" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "运行环境",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。如需更换版本,请先在应用中心安装对应版本。"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"field": "wizard_node_version",
|
||||
"label": "Node.js 版本",
|
||||
"initValue": "22",
|
||||
"options": [
|
||||
{ "label": "Node.js v22 (推荐, LTS)", "value": "22" },
|
||||
{ "label": "Node.js v24", "value": "24" },
|
||||
{ "label": "Node.js v20", "value": "20" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stepTitle": "访问配置",
|
||||
"items": [
|
||||
{
|
||||
"type": "text",
|
||||
"field": "wizard_port",
|
||||
"label": "Web 访问端口",
|
||||
"initValue": "5300",
|
||||
"rules": [
|
||||
{ "required": true, "message": "请输入访问端口" },
|
||||
{ "pattern": "^[0-9]+$", "message": "端口只能是数字" },
|
||||
{ "min": 1, "max": 5, "message": "端口号长度不正确" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.8"
|
||||
version = "4.10.11"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.5.5",
|
||||
"langbot-plugin==0.5.8",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -81,6 +81,7 @@ dependencies = [
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
|
||||
"webauthn>=3.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
@@ -109,12 +110,13 @@ classifiers = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
seekdb = [
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"pyseekdb==1.4.0.post1",
|
||||
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
Documentation = "https://langbot.app/docs"
|
||||
Repository = "https://github.com/langbot-app/LangBot"
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||
## Kubernetes
|
||||
|
||||
See `docker/kubernetes.yaml` and the deployment guide at
|
||||
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
|
||||
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
|
||||
|
||||
## config.yaml (generated at `data/config.yaml` on first run)
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ Two kinds of key are accepted:
|
||||
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
|
||||
scopes do not authorize a tool gets `403 Forbidden`.
|
||||
|
||||
To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key.
|
||||
|
||||
## Client configuration
|
||||
|
||||
```json
|
||||
@@ -86,6 +88,38 @@ already have a default pipeline.
|
||||
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
|
||||
`delete_*` as needed.
|
||||
|
||||
## ChatGPT / Codex subscription providers
|
||||
|
||||
`list_model_providers` can return the `openai-codex` requester. Its OAuth
|
||||
credentials are server-only and are not provider API keys. Never ask a user
|
||||
to paste ChatGPT access tokens, refresh tokens, or a Codex auth cache into an
|
||||
MCP tool or model configuration.
|
||||
|
||||
A human connects or disconnects the subscription through **Models → provider
|
||||
settings** in the LangBot web UI. The provider-scoped `/codex/*` authentication
|
||||
routes deliberately require a browser-user session and are not exposed as MCP
|
||||
tools or authorized by a LangBot API key. Once connected, models are managed
|
||||
and selected through the normal provider/model workflow. A disconnected
|
||||
provider must be reauthorized; do not silently replace it with API-key billing.
|
||||
|
||||
See [ChatGPT / Codex subscription](../../../docs/CODEX_SUBSCRIPTION.md) for setup,
|
||||
usage limits, and the personal-account versus shared-service boundary.
|
||||
|
||||
## Provider deletion
|
||||
|
||||
The curated MCP surface currently lists providers but has no provider-deletion
|
||||
tool. In the web UI, **Edit Provider → Delete** asks for confirmation before
|
||||
removing that provider and all its LLM, embedding, and rerank models. This is
|
||||
irreversible; never interpret a request to edit a provider as authorization to
|
||||
delete it.
|
||||
|
||||
The equivalent HTTP operation is
|
||||
`DELETE /api/v1/provider/providers/{uuid}?cascade=true`, requiring
|
||||
`resource.manage` in the authenticated Workspace. Omitting `cascade` preserves
|
||||
the existing refusal to delete providers that still have models. Cloud-managed
|
||||
providers remain protected. Cascade deletion removes stored Codex authorization
|
||||
state as well; it is not the same operation as disconnecting an account.
|
||||
|
||||
## Implementation & maintenance (for LangBot developers)
|
||||
|
||||
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
|
||||
|
||||
@@ -25,7 +25,10 @@ CLI uses. Create one in your Space account (Profile → Personal Access Tokens),
|
||||
then send it as a Bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
|
||||
Authorization: Bearer <your-pat>
|
||||
```
|
||||
|
||||
Requests without a valid PAT get `401 Unauthorized`.
|
||||
|
||||
## Client configuration
|
||||
|
||||
@@ -66,6 +69,36 @@ All tools are read-only.
|
||||
state (available, unprobed, unavailable), then Space recommendation. Each
|
||||
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
|
||||
|
||||
## Runner usage recommendations
|
||||
|
||||
Use `search_plugins` with `runner_usage: "agent"` for Agent, pipeline, and
|
||||
setup-wizard recommendations, or `runner_usage: "event"` for event processors.
|
||||
The component kind remains `Runner`. Only these two exact values are accepted;
|
||||
omit the optional field to preserve unfiltered browsing.
|
||||
|
||||
```json
|
||||
{"query":"", "runner_usage":"agent", "page":1, "page_size":100}
|
||||
```
|
||||
|
||||
Plugin results include `latest_version` and `runner_usages: string[]`, the
|
||||
explicit union of usages in that latest installable version. Only recommend a
|
||||
plugin when this array explicitly contains the target usage. Missing, empty,
|
||||
malformed, or unknown usages must never mean agent-compatible. Event-only
|
||||
plugins must never enter Agent recommendations. Empty filtered results are
|
||||
valid while legacy packages await corrected releases; never remove the filter
|
||||
to fill a recommendation list.
|
||||
|
||||
REST callers use `runner_usage` on both
|
||||
`POST /api/v1/marketplace/extensions/search` and the compatibility
|
||||
`POST /api/v1/marketplace/plugins/search`; preserve it during fallback. Add
|
||||
`"type_filter":"plugin", "component_filter":"Runner"` on the unified endpoint.
|
||||
Usage is ANDed with other filters before pagination and `total`; MCP/Skill items
|
||||
do not match. Invalid REST values return HTTP 400.
|
||||
|
||||
Open the same filter in the webpage:
|
||||
`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent`
|
||||
(or `runner_usage=event`). Switch All / Agent / Event in the Runner usage row.
|
||||
|
||||
## Implementation & maintenance (for Space developers)
|
||||
|
||||
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
|
||||
|
||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
||||
|___/
|
||||
|
||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||
📖 Documentation 文档地址: https://docs.langbot.app
|
||||
📖 Documentation 文档地址: https://langbot.app/docs
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
||||
template_params = dict(card_param_map or {})
|
||||
if card_data_config is not None:
|
||||
cardData['config'] = json.dumps(card_data_config)
|
||||
template_params['config'] = card_data_config
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||
|
||||
body: dict = {
|
||||
'cardTemplateId': card_template_id,
|
||||
|
||||
@@ -15,6 +15,7 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ....core.errors import TaskCapacityError
|
||||
from ....provider.modelmgr.codex_errors import CodexProviderError
|
||||
from ..authz import (
|
||||
AuthenticationDeniedError,
|
||||
AuthorizationError,
|
||||
@@ -247,6 +248,8 @@ class RouterGroup(abc.ABC):
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
except Exception as e: # 自动 500
|
||||
if isinstance(e, CodexProviderError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, AuthorizationError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, WorkspaceNotFoundError):
|
||||
|
||||
@@ -5,6 +5,7 @@ import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...service.monitoring_traffic import get_traffic_series
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -218,6 +219,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
user_query = quart.request.args.get('userQuery')
|
||||
is_active_str = quart.request.args.get('isActive')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
@@ -237,6 +239,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_query=user_query,
|
||||
is_active=is_active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -375,6 +378,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'traffic': await get_traffic_series(
|
||||
self.ap,
|
||||
request_context,
|
||||
bot_ids=bot_ids or None,
|
||||
pipeline_ids=pipeline_ids or None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
),
|
||||
'overview': overview,
|
||||
'messages': messages,
|
||||
'llmCalls': llm_calls,
|
||||
@@ -396,7 +407,15 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
||||
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||
request_context,
|
||||
session_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
bot_id=quart.request.args.get('botId'),
|
||||
)
|
||||
|
||||
# Always return success with the analysis data
|
||||
# The frontend will handle the 'found: false' case
|
||||
|
||||
@@ -3,6 +3,7 @@ import quart
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
|
||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||
@@ -16,7 +17,12 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
if provider_uuid:
|
||||
models = await self.ap.llm_model_service.get_llm_models_by_provider(
|
||||
request_context,
|
||||
@@ -53,10 +59,16 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
model = await self.ap.llm_model_service.get_llm_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@@ -111,7 +123,12 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
if provider_uuid:
|
||||
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
request_context,
|
||||
@@ -148,10 +165,16 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
model = await self.ap.embedding_models_service.get_embedding_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@@ -208,7 +231,12 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
if provider_uuid:
|
||||
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
|
||||
request_context,
|
||||
@@ -245,10 +273,16 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
model = await self.ap.rerank_models_service.get_rerank_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
|
||||
@@ -3,11 +3,86 @@ import quart
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
|
||||
@group.group_class('models/providers', '/api/v1/provider/providers')
|
||||
class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
# Subscription authorization is an interactive, browser-user-only surface.
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/status',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_status(provider_uuid: str, request_context: RequestContext):
|
||||
try:
|
||||
return self.success(
|
||||
data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/device',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_device(provider_uuid: str, request_context: RequestContext):
|
||||
try:
|
||||
return self.success(
|
||||
data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/device/poll',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_poll(provider_uuid: str, request_context: RequestContext):
|
||||
body = await quart.request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return self.http_status(400, -1, 'JSON object required')
|
||||
try:
|
||||
return self.success(
|
||||
data=await self.ap.provider_service.codex_auth.poll(
|
||||
request_context, provider_uuid, body.get('authorization_id')
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/auth',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_disconnect(provider_uuid: str, request_context: RequestContext):
|
||||
try:
|
||||
await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/device/<authorization_id>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext):
|
||||
try:
|
||||
await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id)
|
||||
return self.success()
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
@@ -15,9 +90,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
providers = await self.ap.provider_service.get_providers(
|
||||
request_context,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
for provider in providers:
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
|
||||
@@ -47,10 +128,16 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
provider = await self.ap.provider_service.get_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if provider is None:
|
||||
return self.http_status(404, -1, 'provider not found')
|
||||
@@ -82,7 +169,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
||||
cascade_values = quart.request.args.getlist('cascade')
|
||||
if cascade_values:
|
||||
if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'):
|
||||
return self.http_status(400, -1, 'cascade must be a single true or false value')
|
||||
await self.ap.provider_service.delete_provider(
|
||||
request_context, provider_uuid, cascade=cascade_values[0] == 'true'
|
||||
)
|
||||
else:
|
||||
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def resolve_include_secret(raw_value: str | None, *, permitted: bool) -> tuple[bool, str | None]:
|
||||
"""Resolve the optional secret projection query parameter."""
|
||||
|
||||
if raw_value is None:
|
||||
return permitted, None
|
||||
|
||||
value = raw_value.strip().lower()
|
||||
if value == 'false':
|
||||
return False, None
|
||||
if value == 'true':
|
||||
return permitted, None
|
||||
return False, 'include_secret must be either true or false'
|
||||
@@ -7,14 +7,116 @@ from .. import group
|
||||
from .....utils import constants
|
||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...context import PrincipalType, RequestContext
|
||||
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
|
||||
|
||||
SYSTEM_CAPABILITY_OPERATIONS = (
|
||||
'bot.list',
|
||||
'bot.get',
|
||||
'bot.create',
|
||||
'bot.update',
|
||||
'bot.delete',
|
||||
'pipeline.list',
|
||||
'pipeline.get',
|
||||
'pipeline.create',
|
||||
'pipeline.update',
|
||||
'pipeline.delete',
|
||||
'pipeline.copy',
|
||||
'task.list',
|
||||
'task.get',
|
||||
'knowledge_base.list',
|
||||
'knowledge_base.get',
|
||||
'knowledge_base.create',
|
||||
'knowledge_base.update',
|
||||
'knowledge_base.delete',
|
||||
'knowledge_base.file.list',
|
||||
'knowledge_base.file.store',
|
||||
'knowledge_base.file.delete',
|
||||
'knowledge_base.retrieve',
|
||||
'file.document.upload',
|
||||
'plugin.install.github',
|
||||
'plugin.install.marketplace',
|
||||
'plugin.install.local',
|
||||
'plugin.upgrade',
|
||||
'plugin.get',
|
||||
'plugin.list',
|
||||
'plugin.config.get',
|
||||
'plugin.config.update',
|
||||
'plugin.logs',
|
||||
'plugin.delete',
|
||||
'provider.list',
|
||||
'provider.get',
|
||||
'provider.create',
|
||||
'provider.update',
|
||||
'provider.delete',
|
||||
'provider.scan_models',
|
||||
'model.llm.list',
|
||||
'model.llm.get',
|
||||
'model.llm.create',
|
||||
'model.llm.update',
|
||||
'model.llm.delete',
|
||||
'model.llm.test',
|
||||
'model.embedding.list',
|
||||
'model.embedding.get',
|
||||
'model.embedding.create',
|
||||
'model.embedding.update',
|
||||
'model.embedding.delete',
|
||||
'model.embedding.test',
|
||||
'model.rerank.list',
|
||||
'model.rerank.get',
|
||||
'model.rerank.create',
|
||||
'model.rerank.update',
|
||||
'model.rerank.delete',
|
||||
'model.rerank.test',
|
||||
'skill.list',
|
||||
'skill.get',
|
||||
'skill.create',
|
||||
'skill.update',
|
||||
'skill.delete',
|
||||
'skill.files.list',
|
||||
'skill.files.read',
|
||||
'skill.files.write',
|
||||
'skill.preview',
|
||||
'skill.install.github',
|
||||
'skill.install.upload',
|
||||
'mcp_server.list',
|
||||
'mcp_server.get',
|
||||
'mcp_server.create',
|
||||
'mcp_server.update',
|
||||
'mcp_server.delete',
|
||||
'mcp_server.resources',
|
||||
'mcp_server.resource_templates',
|
||||
'mcp_server.resource_read',
|
||||
'mcp_server.logs',
|
||||
'mcp_server.test',
|
||||
)
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
class SystemRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(
|
||||
data={
|
||||
'instance_uuid': request_context.instance_uuid,
|
||||
'workspace_uuid': request_context.workspace_uuid,
|
||||
'api_key_id': request_context.principal.api_key_uuid,
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(
|
||||
data={
|
||||
'schema_version': 1,
|
||||
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Read wizard_status and wizard_progress from metadata table
|
||||
@@ -223,7 +325,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
@@ -242,18 +344,23 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
public=request_context.principal.principal_type == PrincipalType.API_KEY,
|
||||
)
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/tasks/<task_id>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(task_id: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
task_index = int(task_id)
|
||||
except (TypeError, ValueError):
|
||||
return self.http_status(404, 404, 'Task not found')
|
||||
task = self.ap.task_mgr.get_task_by_id(
|
||||
int(task_id),
|
||||
task_index,
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
@@ -262,6 +369,8 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
if task is None:
|
||||
return self.http_status(404, 404, 'Task not found')
|
||||
|
||||
if request_context.principal.principal_type == PrincipalType.API_KEY:
|
||||
return self.success(data=task.to_public_dict())
|
||||
return self.success(data=task.to_dict())
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import datetime
|
||||
import hmac
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -11,6 +16,33 @@ from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
|
||||
# The admission check and slot bump share ONE synchronous critical section with no await
|
||||
# points, so concurrent bursts within a single event loop cannot slip past accounting.
|
||||
# Every admitted attempt consumes quota (regardless of success), which throttles both the
|
||||
# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.
|
||||
# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.
|
||||
_MAX_RESET_ATTEMPTS_PER_WINDOW = 5
|
||||
_RESET_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0}
|
||||
|
||||
|
||||
def _admit_reset_attempt(now: float) -> bool:
|
||||
"""Atomically reserve one reset-password admission slot.
|
||||
|
||||
Must stay await-free: running to completion without suspension makes the
|
||||
check-and-increment atomic under the single-threaded event loop.
|
||||
"""
|
||||
st = _reset_password_state
|
||||
if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS:
|
||||
st['window_started_at'] = now
|
||||
st['attempts'] = 0
|
||||
if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW:
|
||||
return False
|
||||
st['attempts'] += 1
|
||||
return True
|
||||
|
||||
|
||||
@group.group_class('user', '/api/v1/user')
|
||||
class UserRouterGroup(group.RouterGroup):
|
||||
@@ -35,6 +67,22 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
return redirect_uri
|
||||
|
||||
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
|
||||
origin = ''
|
||||
if json_data and isinstance(json_data, dict):
|
||||
origin = json_data.get('origin', '')
|
||||
if not origin:
|
||||
origin = quart.request.headers.get('Origin', '')
|
||||
if not origin:
|
||||
origin = quart.request.headers.get('Referer', '')
|
||||
if not origin:
|
||||
origin = quart.request.url_root.rstrip('/')
|
||||
|
||||
parsed = urlsplit(origin)
|
||||
rp_id = parsed.hostname or 'localhost'
|
||||
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
|
||||
return clean_origin, rp_id
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
@@ -81,6 +129,12 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Admit (or reject) BEFORE touching the body or any service call (#2392):
|
||||
# rejecting requests never reach the slow path, and quota accounting happens
|
||||
# synchronously at entry, closing the post-await race of burst requests.
|
||||
if not _admit_reset_attempt(time.monotonic()):
|
||||
return self.http_status(429, -1, 'Too many attempts, try again later')
|
||||
|
||||
json_data = await quart.request.json
|
||||
|
||||
user_email = json_data['user']
|
||||
@@ -98,7 +152,18 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if user_obj is None:
|
||||
return self.http_status(400, -1, 'User not found')
|
||||
|
||||
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
|
||||
stored_key = self.ap.instance_config.data['system']['recovery_key']
|
||||
try:
|
||||
key_matches = (
|
||||
isinstance(recovery_key, str)
|
||||
and isinstance(stored_key, str)
|
||||
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
# JSON can contain lone surrogates, which are not valid UTF-8.
|
||||
key_matches = False
|
||||
|
||||
if not key_matches:
|
||||
return self.http_status(403, -1, 'Invalid recovery key')
|
||||
|
||||
await self.ap.user_service.reset_password(user_email, new_password)
|
||||
@@ -186,6 +251,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback'
|
||||
)
|
||||
launch_assertion = json_data.get('launch_assertion')
|
||||
workspace_uuid = json_data.get('workspace_uuid')
|
||||
|
||||
@@ -199,8 +267,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
if not state:
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
# Exchange code for tokens
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
@@ -218,24 +289,36 @@ class UserRouterGroup(group.RouterGroup):
|
||||
code,
|
||||
workspace_uuids,
|
||||
workspace_created_ats,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# Authenticate and create/update local user
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||
if cloud_mode:
|
||||
if not target_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||
|
||||
# Authenticate only after the signed, exact Workspace delta has
|
||||
# established the Account and membership runtime shadow rows.
|
||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
if launch_workspace_uuid:
|
||||
if target_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
launch_workspace_uuid,
|
||||
target_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
@@ -323,6 +406,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
capabilities['password_login_enabled'] = False
|
||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
capabilities['passkey_login_enabled'] = True
|
||||
capabilities['passkey_supported'] = True
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@@ -367,12 +452,17 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
|
||||
)
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||
@@ -385,7 +475,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||
|
||||
try:
|
||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
|
||||
updated_user = await self.ap.user_service.bind_space_account(
|
||||
user_obj.user, code, redirect_uri=redirect_uri
|
||||
)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||
return self.success(
|
||||
data={
|
||||
@@ -405,6 +498,182 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Generate WebAuthn registration options for current account."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = (await quart.request.json) or {}
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
|
||||
account_uuid=user_obj.uuid,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
rp_name='LangBot',
|
||||
)
|
||||
return self.success(data={'options': options, 'challenge_token': challenge_token})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Verify WebAuthn registration response and save credential."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
challenge_token = json_data.get('challenge_token')
|
||||
credential = json_data.get('credential') or json_data.get('response')
|
||||
name = json_data.get('name')
|
||||
|
||||
if not challenge_token or not credential:
|
||||
return self.fail(1, 'Missing challenge_token or credential')
|
||||
|
||||
try:
|
||||
cred = await self.ap.user_service.verify_and_save_passkey_registration(
|
||||
challenge_token=challenge_token,
|
||||
credential_data=credential,
|
||||
name=name,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'uuid': cred.uuid,
|
||||
'name': cred.name,
|
||||
'created_at': cred.created_at.isoformat() if cred.created_at else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Generate WebAuthn authentication options for passkey login."""
|
||||
json_data = (await quart.request.json) or {}
|
||||
email = json_data.get('email')
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
email=email,
|
||||
)
|
||||
return self.success(data={'options': options, 'challenge_token': challenge_token})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Verify WebAuthn authentication response and log in."""
|
||||
json_data = await quart.request.json
|
||||
challenge_token = json_data.get('challenge_token')
|
||||
credential = json_data.get('credential') or json_data.get('response')
|
||||
|
||||
if not challenge_token or not credential:
|
||||
return self.fail(1, 'Missing challenge_token or credential')
|
||||
|
||||
try:
|
||||
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
|
||||
challenge_token=challenge_token,
|
||||
credential_data=credential,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
'user': user_obj.user,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""List registered passkeys for the current user."""
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
|
||||
return self.success(
|
||||
data=[
|
||||
{
|
||||
'uuid': pk.uuid,
|
||||
'name': pk.name,
|
||||
'aaguid': pk.aaguid,
|
||||
'transports': pk.transports,
|
||||
'backed_up': pk.backed_up,
|
||||
'created_at': pk.created_at.isoformat() if pk.created_at else None,
|
||||
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
|
||||
}
|
||||
for pk in passkeys
|
||||
]
|
||||
)
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Rename a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
name = (json_data.get('name') or '').strip()
|
||||
if not name:
|
||||
return self.fail(1, 'Passkey name cannot be empty')
|
||||
|
||||
updated = await self.ap.user_service.rename_user_passkey(
|
||||
account_uuid=user_obj.uuid,
|
||||
passkey_uuid=passkey_uuid,
|
||||
new_name=name,
|
||||
)
|
||||
if not updated:
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Delete/revoke a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
deleted = await self.ap.user_service.delete_user_passkey(
|
||||
account_uuid=user_obj.uuid,
|
||||
passkey_uuid=passkey_uuid,
|
||||
)
|
||||
if not deleted:
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success()
|
||||
|
||||
async def _handle_space_direct_launch(
|
||||
self,
|
||||
launch_assertion: str,
|
||||
@@ -428,6 +697,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
projection_service = self.ap.directory_projection_service
|
||||
if projection_service is None:
|
||||
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
|
||||
@@ -137,7 +137,16 @@ class BotService:
|
||||
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
try:
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
except Exception:
|
||||
# The bot row was already inserted above; without this rollback a
|
||||
# failing adapter constructor (e.g. a missing optional credential
|
||||
# key) would leave a permanently disabled orphan bot in the DB.
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
|
||||
)
|
||||
raise
|
||||
|
||||
return bot_data['uuid']
|
||||
|
||||
|
||||
@@ -446,15 +446,19 @@ class MCPService:
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
async def _refresh_and_report() -> None:
|
||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
else:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
try:
|
||||
needs_start = (
|
||||
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
)
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
else:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
await persisted_session.start()
|
||||
finally:
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
|
||||
coroutine = _refresh_and_report()
|
||||
else:
|
||||
@@ -471,8 +475,11 @@ class MCPService:
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
finally:
|
||||
# start() raises for a failed connection. Preserve the
|
||||
# terminal runtime state so the UI can render actionable
|
||||
# failure phases such as OAuth-required.
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -29,6 +29,19 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
|
||||
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
|
||||
|
||||
|
||||
def _normalize_user_id(value: str | int | None) -> str | None:
|
||||
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
|
||||
|
||||
Opaque string IDs (including whitespace and leading zeros) and missing
|
||||
IDs must remain unchanged. Do not silently stringify unsupported objects.
|
||||
"""
|
||||
if value is None or isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
raise TypeError('user_id must be a string, integer, or None')
|
||||
|
||||
|
||||
def _workspace_transaction(method):
|
||||
"""Run an explicit service entrypoint in one Workspace transaction."""
|
||||
|
||||
@@ -281,19 +294,21 @@ class MonitoringService:
|
||||
for _batch_number in range(max_batches):
|
||||
|
||||
async def delete_batch() -> tuple[int, int]:
|
||||
key_columns = list(model_cls.__table__.primary_key.columns)
|
||||
select_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(pk_column)
|
||||
sqlalchemy.select(*key_columns)
|
||||
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pk_values = list(select_result.scalars().all())
|
||||
pk_values = [tuple(row) for row in select_result.all()]
|
||||
if not pk_values:
|
||||
return 0, 0
|
||||
|
||||
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(model_cls).where(
|
||||
model_cls.workspace_uuid == workspace_uuid,
|
||||
pk_column.in_(pk_values),
|
||||
sqlalchemy.tuple_(*key_columns).in_(pk_values),
|
||||
ts_column < cutoff,
|
||||
)
|
||||
)
|
||||
return len(pk_values), int(delete_result.rowcount or 0)
|
||||
@@ -415,7 +430,7 @@ class MonitoringService:
|
||||
status: str = 'success',
|
||||
level: str = 'info',
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
runner_name: str | None = None,
|
||||
variables: str | None = None,
|
||||
@@ -437,7 +452,7 @@ class MonitoringService:
|
||||
'status': status,
|
||||
'level': level,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
@@ -610,7 +625,7 @@ class MonitoringService:
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
@@ -622,17 +637,29 @@ class MonitoringService:
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'message_count': 0,
|
||||
'message_count': 1,
|
||||
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
model = persistence_monitoring.MonitoringSession
|
||||
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
|
||||
statement = insert(model).values(session_data)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
|
||||
set_={
|
||||
'message_count': model.message_count + 1,
|
||||
'last_activity': statement.excluded.last_activity,
|
||||
'pipeline_id': statement.excluded.pipeline_id,
|
||||
'pipeline_name': statement.excluded.pipeline_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@_workspace_transaction
|
||||
@@ -642,6 +669,7 @@ class MonitoringService:
|
||||
session_id: str,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Update session last activity time and increment message count.
|
||||
|
||||
@@ -651,6 +679,9 @@ class MonitoringService:
|
||||
True if session was found and updated, False if session doesn't exist.
|
||||
"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
bot_id = bot_id if bot_id is not None else context.bot_uuid
|
||||
if not bot_id:
|
||||
raise ValueError('Session activity requires a bot_id')
|
||||
update_values = {
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
||||
@@ -667,6 +698,7 @@ class MonitoringService:
|
||||
.where(
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
persistence_monitoring.MonitoringSession.bot_id == bot_id,
|
||||
)
|
||||
.values(update_values)
|
||||
)
|
||||
@@ -769,13 +801,13 @@ class MonitoringService:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
|
||||
if end_time:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
|
||||
# Total messages
|
||||
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
||||
@@ -1257,6 +1289,7 @@ class MonitoringService:
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
user_query: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -1271,9 +1304,17 @@ class MonitoringService:
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
sqlalchemy.or_(
|
||||
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||
)
|
||||
)
|
||||
if is_active is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||
|
||||
@@ -1365,6 +1406,9 @@ class MonitoringService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Get bounded session details with full statistics computed in SQL."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -1374,8 +1418,13 @@ class MonitoringService:
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query)
|
||||
session_row = session_result.first()
|
||||
if bot_id is not None:
|
||||
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
|
||||
session_rows = session_result.all()
|
||||
if len(session_rows) > 1:
|
||||
return {'session_id': session_id, 'found': False, 'ambiguous': True}
|
||||
session_row = session_rows[0] if session_rows else None
|
||||
|
||||
if not session_row:
|
||||
return {
|
||||
@@ -1384,6 +1433,7 @@ class MonitoringService:
|
||||
}
|
||||
|
||||
session = session_row[0] if isinstance(session_row, tuple) else session_row
|
||||
bot_id = session.bot_id
|
||||
|
||||
message_stats_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(
|
||||
@@ -1411,6 +1461,7 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
||||
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
message_stats = message_stats_result.one()
|
||||
@@ -1449,6 +1500,7 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
llm_stats = llm_stats_result.one()
|
||||
@@ -1475,15 +1527,22 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
tool_stats = tool_stats_result.one()
|
||||
tool_conditions = [
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
||||
]
|
||||
if start_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||
tool_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
)
|
||||
.where(*tool_conditions)
|
||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||
.limit(detail_limit + 1)
|
||||
)
|
||||
@@ -1504,6 +1563,7 @@ class MonitoringService:
|
||||
.where(
|
||||
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringError.session_id == session_id,
|
||||
persistence_monitoring.MonitoringError.bot_id == bot_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
|
||||
.limit(detail_limit + 1)
|
||||
@@ -1988,9 +2048,9 @@ class MonitoringService:
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
||||
persistence_monitoring.MonitoringSession.last_activity.desc()
|
||||
@@ -2024,6 +2084,7 @@ class MonitoringService:
|
||||
|
||||
# ========== Feedback Methods ==========
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_feedback(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
@@ -2038,7 +2099,7 @@ class MonitoringService:
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
stream_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||
@@ -2094,7 +2155,7 @@ class MonitoringService:
|
||||
'session_id': session_id,
|
||||
'message_id': message_id,
|
||||
'stream_id': stream_id,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'platform': platform,
|
||||
}
|
||||
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Bounded traffic aggregation, independent of record-list pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
MAX_TRAFFIC_POINTS = 1000
|
||||
|
||||
|
||||
async def get_traffic_series(
|
||||
ap: Application,
|
||||
context: TenantContext,
|
||||
*,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Count all matching records in UTC buckets, returning at most 1000 points."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
|
||||
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
|
||||
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
|
||||
points: dict[datetime.datetime, dict[str, int]] = {}
|
||||
truncated = False
|
||||
for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')):
|
||||
timestamp = model.timestamp
|
||||
if postgres:
|
||||
time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp)
|
||||
else:
|
||||
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
|
||||
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
|
||||
conditions = [model.workspace_uuid == workspace_uuid]
|
||||
if bot_ids:
|
||||
conditions.append(model.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(model.pipeline_id.in_(pipeline_ids))
|
||||
if start_time is not None:
|
||||
conditions.append(timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
conditions.append(timestamp <= end_time)
|
||||
statement = (
|
||||
sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count'))
|
||||
.where(*conditions)
|
||||
.group_by(time_bucket)
|
||||
.order_by(time_bucket)
|
||||
.limit(MAX_TRAFFIC_POINTS + 1)
|
||||
)
|
||||
result = await ap.persistence_mgr.execute_async(statement)
|
||||
rows = result.all()
|
||||
truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS
|
||||
for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]:
|
||||
key = (
|
||||
datetime.datetime.fromisoformat(timestamp_value)
|
||||
if isinstance(timestamp_value, str)
|
||||
else timestamp_value
|
||||
)
|
||||
points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count)
|
||||
|
||||
def floor(value: datetime.datetime) -> datetime.datetime:
|
||||
return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {}))
|
||||
|
||||
first = floor(start_time) if start_time is not None else min(points, default=None)
|
||||
last = floor(end_time) if end_time is not None else max(points, default=None)
|
||||
series = []
|
||||
if first is not None and last is not None:
|
||||
cursor = first
|
||||
while cursor <= last and len(series) < MAX_TRAFFIC_POINTS:
|
||||
series.append(
|
||||
{'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})}
|
||||
)
|
||||
cursor += step
|
||||
truncated = truncated or cursor <= last
|
||||
return {'bucket': bucket, 'points': series, 'truncated': truncated}
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
@@ -7,8 +8,10 @@ import sqlalchemy
|
||||
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
from ....core import app
|
||||
from ....core.task_boundary import create_detached_task
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....provider.modelmgr.codex_auth import CodexAuth, REQUESTER as CODEX_REQUESTER, validate_config
|
||||
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
@@ -20,6 +23,8 @@ class ModelProviderService:
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self.codex_auth = CodexAuth(ap)
|
||||
self._deletion_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
def _is_cloud_runtime(self) -> bool:
|
||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
||||
@@ -116,14 +121,30 @@ class ModelProviderService:
|
||||
provider_data = provider_data.copy()
|
||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
validate_config(provider_data)
|
||||
provider_data['uuid'] = str(uuid.uuid4())
|
||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||
provider_data['api_keys'] = self._normalize_api_keys(
|
||||
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
if provider_data.get('requester') == CODEX_REQUESTER:
|
||||
async with self.ap.persistence_mgr.tenant_uow(provider_data['workspace_uuid']):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.CodexCredential).values(
|
||||
workspace_uuid=provider_data['workspace_uuid'],
|
||||
provider_uuid=provider_data['uuid'],
|
||||
payload={},
|
||||
version=0,
|
||||
lease_until=0,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
|
||||
# load to runtime
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
|
||||
@@ -138,6 +159,17 @@ class ModelProviderService:
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
provider_data.pop('uuid', None)
|
||||
provider_data.pop('workspace_uuid', None)
|
||||
if {'requester', 'base_url', 'api_keys'} & provider_data.keys():
|
||||
current = await self.get_provider(context, provider_uuid, include_secret=True)
|
||||
if current is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if CODEX_REQUESTER in (current.get('requester'), provider_data.get('requester')):
|
||||
if provider_data.get('requester', current.get('requester')) != current.get('requester'):
|
||||
raise ValueError('Create a separate provider to change the ChatGPT authentication type')
|
||||
merged = {**current, **provider_data}
|
||||
validate_config(merged)
|
||||
provider_data['base_url'] = merged['base_url']
|
||||
provider_data['api_keys'] = []
|
||||
if 'api_keys' in provider_data:
|
||||
submitted_keys = provider_data.get('api_keys')
|
||||
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
||||
@@ -163,60 +195,107 @@ class ModelProviderService:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
||||
|
||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
"""Delete a provider (only if no models reference it)"""
|
||||
await self._assert_provider_mutable(context, provider_uuid)
|
||||
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
|
||||
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check if any models use this provider
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.LLMModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
persistence = self.ap.persistence_mgr
|
||||
model_types = (
|
||||
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
|
||||
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
|
||||
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
|
||||
)
|
||||
if llm_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: LLM models still reference it')
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.EmbeddingModel,
|
||||
workspace_uuid,
|
||||
deleted_models: list[tuple[str, list[str]]] = []
|
||||
async with persistence.tenant_uow(workspace_uuid):
|
||||
# Check ownership before touching children. Lock the provider on PostgreSQL
|
||||
# so concurrent model inserts cannot race the reference check/deletion.
|
||||
provider_result = await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider.requester)
|
||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||
.with_for_update(),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
if embedding_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||
provider = provider_result.first()
|
||||
if provider is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if self._system_requester_is_reserved(provider.requester):
|
||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
||||
|
||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.RerankModel,
|
||||
workspace_uuid,
|
||||
for model_type, label, remover in model_types:
|
||||
result = await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
|
||||
model_type,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
model_uuids = list(result.scalars())
|
||||
if model_uuids and not cascade:
|
||||
raise ValueError(f'Cannot delete provider: {label} models still reference it')
|
||||
if model_uuids:
|
||||
# Model services have no pipeline/KB deletion side effects: they
|
||||
# delete the scoped row and evict its runtime cache. Defer eviction
|
||||
# here rather than calling those services before our commit.
|
||||
await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid),
|
||||
model_type,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
deleted_models.append((remover, model_uuids))
|
||||
|
||||
# Explicit cleanup also works on legacy SQLite connections without FK
|
||||
# enforcement; never load or serialize the private credential payload.
|
||||
await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.CodexCredential).where(
|
||||
persistence_model.CodexCredential.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.CodexCredential,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
if rerank_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
result = await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if result.rowcount == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
|
||||
await self.ap.model_mgr.remove_provider(context, provider_uuid)
|
||||
async def remove_runtime() -> None:
|
||||
async with persistence.tenant_scope(workspace_uuid):
|
||||
for remover, model_uuids in deleted_models:
|
||||
for model_uuid in model_uuids:
|
||||
await getattr(self.ap.model_mgr, remover)(context, model_uuid)
|
||||
# This also closes the requester's HTTP client; models go first.
|
||||
await self.ap.model_mgr.remove_provider(context, provider_uuid)
|
||||
|
||||
if persistence.current_session() is None:
|
||||
await remove_runtime()
|
||||
else:
|
||||
# A nested UoW has not committed yet. Reuse the rollback-cancelled gate
|
||||
# and detached context boundary instead of evicting uncommitted data.
|
||||
task = create_detached_task(
|
||||
remove_runtime(),
|
||||
after_commit_manager=persistence,
|
||||
workspace_uuid=workspace_uuid,
|
||||
)
|
||||
self._deletion_tasks.add(task)
|
||||
|
||||
def completed(task: asyncio.Task[None]) -> None:
|
||||
self._deletion_tasks.discard(task)
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
self.ap.logger.error('Failed to remove deleted provider runtime', exc_info=task.exception())
|
||||
|
||||
task.add_done_callback(completed)
|
||||
|
||||
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
|
||||
"""Get count of models using this provider"""
|
||||
|
||||
@@ -119,7 +119,7 @@ class SpaceService:
|
||||
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = {'redirect_uri': redirect_uri}
|
||||
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||
if state:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
@@ -129,6 +129,8 @@ class SpaceService:
|
||||
code: str,
|
||||
workspace_uuids: list[str] | None = None,
|
||||
workspace_created_ats: dict[str, int] | None = None,
|
||||
*,
|
||||
redirect_uri: str = '',
|
||||
) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
from langbot.pkg.utils import constants
|
||||
@@ -141,6 +143,7 @@ class SpaceService:
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'instance_id': constants.instance_id,
|
||||
# Sending an explicit empty list tells new Space servers not to
|
||||
# synthesize a legacy instance-derived Workspace binding.
|
||||
|
||||
@@ -4,6 +4,7 @@ import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
import datetime
|
||||
import json
|
||||
import typing
|
||||
import asyncio
|
||||
import dataclasses
|
||||
@@ -12,10 +13,19 @@ import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
import webauthn
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ....entity.persistence import user
|
||||
from ....entity.persistence import passkey
|
||||
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
|
||||
from ....utils import constants
|
||||
from ....entity.errors import account as account_errors
|
||||
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
|
||||
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
|
||||
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
|
||||
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
|
||||
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
|
||||
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
|
||||
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
|
||||
|
||||
|
||||
class AccountExistsLoginRequiredError(ValueError):
|
||||
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
|
||||
launch_workspace_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class PasskeyChallengeData:
|
||||
challenge: bytes
|
||||
purpose: typing.Literal['register', 'auth']
|
||||
rp_id: str
|
||||
origin: str
|
||||
expires_at: float
|
||||
account_uuid: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class UserService:
|
||||
ap: Application
|
||||
_create_user_lock: asyncio.Lock
|
||||
@@ -65,6 +89,9 @@ class UserService:
|
||||
self._space_oauth_state_lock = asyncio.Lock()
|
||||
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
|
||||
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
|
||||
self._passkey_challenge_lock = asyncio.Lock()
|
||||
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
|
||||
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
|
||||
|
||||
@staticmethod
|
||||
def _space_oauth_state_digest(state: str) -> str:
|
||||
@@ -774,7 +801,7 @@ class UserService:
|
||||
f'email:{normalized_email}',
|
||||
)
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
||||
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||
"""Bind Space account to existing local account"""
|
||||
local_account = await self.get_user_by_email(user_email)
|
||||
if local_account is None:
|
||||
@@ -794,12 +821,13 @@ class UserService:
|
||||
code,
|
||||
[binding.workspace_uuid],
|
||||
{binding.workspace_uuid: created_ts},
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
else:
|
||||
# Compatibility for early/bootstrap call sites that have not wired
|
||||
# WorkspaceService yet; old Space servers still derive the legacy
|
||||
# Workspace identity from instance_id when the field is omitted.
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
@@ -849,3 +877,309 @@ class UserService:
|
||||
await self._update_space_provider_for_account(local_account, api_key)
|
||||
|
||||
return await self.get_user_by_email(space_email)
|
||||
|
||||
def _prune_passkey_challenges(self, now: float) -> None:
|
||||
while self._passkey_challenge_expiry_heap:
|
||||
expires_at, token = self._passkey_challenge_expiry_heap[0]
|
||||
entry = self._passkey_challenges.get(token)
|
||||
if entry is None or entry.expires_at != expires_at:
|
||||
heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
continue
|
||||
if expires_at > now:
|
||||
break
|
||||
heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
self._passkey_challenges.pop(token, None)
|
||||
|
||||
max_heap_entries = max(
|
||||
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
|
||||
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
|
||||
)
|
||||
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
|
||||
self._passkey_challenge_expiry_heap[:] = [
|
||||
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
|
||||
]
|
||||
heapq.heapify(self._passkey_challenge_expiry_heap)
|
||||
|
||||
async def issue_passkey_challenge(
|
||||
self,
|
||||
purpose: typing.Literal['register', 'auth'],
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
*,
|
||||
account_uuid: str | None = None,
|
||||
user_email: str | None = None,
|
||||
ttl_seconds: int = 300,
|
||||
) -> tuple[str, bytes]:
|
||||
now = time.monotonic()
|
||||
challenge_bytes = secrets.token_bytes(32)
|
||||
challenge_token = secrets.token_urlsafe(32)
|
||||
expires_at = now + ttl_seconds
|
||||
|
||||
async with self._passkey_challenge_lock:
|
||||
self._prune_passkey_challenges(now)
|
||||
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
|
||||
if not self._passkey_challenge_expiry_heap:
|
||||
break
|
||||
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
self._passkey_challenges.pop(oldest_token, None)
|
||||
|
||||
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
|
||||
challenge=challenge_bytes,
|
||||
purpose=purpose,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
expires_at=expires_at,
|
||||
account_uuid=account_uuid,
|
||||
user_email=user_email,
|
||||
)
|
||||
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
|
||||
|
||||
return challenge_token, challenge_bytes
|
||||
|
||||
async def consume_passkey_challenge(
|
||||
self,
|
||||
challenge_token: str,
|
||||
purpose: typing.Literal['register', 'auth'],
|
||||
) -> PasskeyChallengeData:
|
||||
now = time.monotonic()
|
||||
async with self._passkey_challenge_lock:
|
||||
self._prune_passkey_challenges(now)
|
||||
data = self._passkey_challenges.pop(challenge_token, None)
|
||||
|
||||
if data is None or data.expires_at < now:
|
||||
raise ValueError('Invalid or expired passkey challenge')
|
||||
if data.purpose != purpose:
|
||||
raise ValueError('Passkey challenge purpose mismatch')
|
||||
return data
|
||||
|
||||
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
|
||||
statement = (
|
||||
sqlalchemy.select(passkey.PasskeyCredential)
|
||||
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
|
||||
.order_by(passkey.PasskeyCredential.created_at.desc())
|
||||
)
|
||||
async with self._session_factory()() as session:
|
||||
result = await session.scalars(statement)
|
||||
return list(result.all())
|
||||
|
||||
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
|
||||
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.credential_id == credential_id
|
||||
)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
|
||||
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def generate_passkey_registration_options(
|
||||
self,
|
||||
account_uuid: str,
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
rp_name: str = 'LangBot',
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
account = await self.get_user_by_uuid(account_uuid)
|
||||
if account is None:
|
||||
raise ValueError('User not found')
|
||||
self._require_active_account(account)
|
||||
|
||||
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
account_uuid=account_uuid,
|
||||
user_email=account.user,
|
||||
)
|
||||
|
||||
existing_passkeys = await self.get_user_passkeys(account_uuid)
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
|
||||
]
|
||||
|
||||
options = webauthn.generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
user_name=account.user,
|
||||
user_id=account.uuid.encode('utf-8'),
|
||||
user_display_name=account.user,
|
||||
challenge=challenge_bytes,
|
||||
exclude_credentials=exclude_credentials or None,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
|
||||
options_dict = json.loads(webauthn.options_to_json(options))
|
||||
return options_dict, challenge_token
|
||||
|
||||
async def verify_and_save_passkey_registration(
|
||||
self,
|
||||
challenge_token: str,
|
||||
credential_data: dict[str, typing.Any] | str,
|
||||
name: str | None = None,
|
||||
) -> passkey.PasskeyCredential:
|
||||
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
|
||||
if not challenge_data.account_uuid:
|
||||
raise ValueError('Registration challenge must be bound to an account')
|
||||
|
||||
verification = webauthn.verify_registration_response(
|
||||
credential=credential_data,
|
||||
expected_challenge=challenge_data.challenge,
|
||||
expected_rp_id=challenge_data.rp_id,
|
||||
expected_origin=challenge_data.origin,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
cred_id_str = bytes_to_base64url(verification.credential_id)
|
||||
pub_key_str = bytes_to_base64url(verification.credential_public_key)
|
||||
|
||||
transports = None
|
||||
if isinstance(credential_data, dict):
|
||||
resp = credential_data.get('response', {})
|
||||
if isinstance(resp, dict) and 'transports' in resp:
|
||||
t_list = resp.get('transports')
|
||||
if isinstance(t_list, list):
|
||||
transports = ','.join(str(x) for x in t_list)
|
||||
|
||||
credential_name = (name or '').strip()
|
||||
if not credential_name:
|
||||
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
|
||||
|
||||
record = passkey.PasskeyCredential(
|
||||
uuid=str(uuid.uuid4()),
|
||||
account_uuid=challenge_data.account_uuid,
|
||||
name=credential_name,
|
||||
credential_id=cred_id_str,
|
||||
public_key=pub_key_str,
|
||||
sign_count=verification.sign_count,
|
||||
aaguid=verification.aaguid,
|
||||
transports=transports,
|
||||
backed_up=verification.credential_backed_up,
|
||||
)
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def generate_passkey_authentication_options(
|
||||
self,
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
email: str | None = None,
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
|
||||
purpose='auth',
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
user_email=email,
|
||||
)
|
||||
|
||||
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
|
||||
if email:
|
||||
user_obj = await self.get_user_by_email(email)
|
||||
if user_obj:
|
||||
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
|
||||
if user_passkeys:
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
|
||||
]
|
||||
|
||||
options = webauthn.generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
challenge=challenge_bytes,
|
||||
allow_credentials=allow_credentials or None,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
options_dict = json.loads(webauthn.options_to_json(options))
|
||||
return options_dict, challenge_token
|
||||
|
||||
async def verify_passkey_authentication(
|
||||
self,
|
||||
challenge_token: str,
|
||||
credential_data: dict[str, typing.Any] | str,
|
||||
) -> tuple[str, user.User]:
|
||||
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
|
||||
|
||||
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
|
||||
if not raw_id:
|
||||
raise ValueError('Missing credential id')
|
||||
|
||||
stored_credential = await self.get_passkey_by_credential_id(raw_id)
|
||||
if stored_credential is None:
|
||||
raise ValueError('Passkey credential not recognized')
|
||||
|
||||
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
|
||||
if user_obj is None:
|
||||
raise ValueError('Associated user not found')
|
||||
self._require_active_account(user_obj)
|
||||
|
||||
verification = webauthn.verify_authentication_response(
|
||||
credential=credential_data,
|
||||
expected_challenge=challenge_data.challenge,
|
||||
expected_rp_id=challenge_data.rp_id,
|
||||
expected_origin=challenge_data.origin,
|
||||
credential_public_key=base64url_to_bytes(stored_credential.public_key),
|
||||
credential_current_sign_count=stored_credential.sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.id == stored_credential.id
|
||||
)
|
||||
)
|
||||
if record:
|
||||
record.sign_count = verification.new_sign_count
|
||||
record.last_used_at = datetime.datetime.now()
|
||||
record.backed_up = verification.credential_backed_up
|
||||
|
||||
token = await self.generate_jwt_token(user_obj)
|
||||
return token, user_obj
|
||||
|
||||
async def rename_user_passkey(
|
||||
self,
|
||||
account_uuid: str,
|
||||
passkey_uuid: str,
|
||||
new_name: str,
|
||||
) -> passkey.PasskeyCredential | None:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.uuid == passkey_uuid,
|
||||
passkey.PasskeyCredential.account_uuid == account_uuid,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return None
|
||||
record.name = new_name
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def delete_user_passkey(
|
||||
self,
|
||||
account_uuid: str,
|
||||
passkey_uuid: str,
|
||||
) -> bool:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.uuid == passkey_uuid,
|
||||
passkey.PasskeyCredential.account_uuid == account_uuid,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return False
|
||||
await session.delete(record)
|
||||
return True
|
||||
|
||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
||||
# The database cursor remains the shared projection high-water mark,
|
||||
# while this cursor tracks what this process has actually observed.
|
||||
self._consumer_cursor: int | None = None
|
||||
self._sync_lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def refresh_snapshot(self) -> None:
|
||||
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def _refresh_snapshot(self) -> None:
|
||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||
for _attempt in range(5):
|
||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
|
||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||
|
||||
async def sync_once(self) -> None:
|
||||
async with self._sync_lock:
|
||||
await self._sync_once()
|
||||
|
||||
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
|
||||
"""Synchronously project an exact Workspace set without moving the event cursor."""
|
||||
|
||||
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
|
||||
if not requested:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
|
||||
if len(requested) > self.event_limit:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
|
||||
async with self._sync_lock:
|
||||
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
|
||||
await self._apply_targeted_delta(delta, requested)
|
||||
|
||||
async def _apply_targeted_delta(
|
||||
self,
|
||||
delta: DirectoryDelta,
|
||||
requested_workspace_uuids: tuple[str, ...],
|
||||
) -> None:
|
||||
if not isinstance(delta, DirectoryDelta):
|
||||
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
|
||||
workspace_count, membership_count = self._validate_batch_capacity(
|
||||
delta.workspaces,
|
||||
full_snapshot=False,
|
||||
)
|
||||
delta = DirectoryDelta.model_validate(delta.model_dump())
|
||||
if delta.instance_uuid != self.instance_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
|
||||
requested = set(requested_workspace_uuids)
|
||||
if set(delta.requested_workspace_uuids) != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
|
||||
if {workspace.uuid for workspace in delta.workspaces} != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
|
||||
|
||||
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
|
||||
if not callable(directory_uow):
|
||||
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
|
||||
|
||||
async with directory_uow(self.instance_uuid) as uow:
|
||||
session = uow.session
|
||||
state = await session.scalar(
|
||||
sqlalchemy.select(DirectoryProjectionState)
|
||||
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if state is None:
|
||||
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
|
||||
snapshot = DirectorySnapshot(
|
||||
instance_uuid=self.instance_uuid,
|
||||
cursor=state.cursor,
|
||||
generated_at=delta.generated_at,
|
||||
workspaces=delta.workspaces,
|
||||
)
|
||||
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
|
||||
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
|
||||
active_workspace_count = await self._enforce_active_workspace_capacity(session)
|
||||
await session.flush()
|
||||
|
||||
await self._update_entitlement_workspace_activity(
|
||||
snapshot.workspaces,
|
||||
requested_workspace_uuids=requested,
|
||||
)
|
||||
self._publish_runtime_execution_projection(
|
||||
snapshot.workspaces,
|
||||
affected_workspace_uuids=requested,
|
||||
)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
memberships=membership_count,
|
||||
)
|
||||
|
||||
async def _sync_once(self) -> None:
|
||||
cursor = self._consumer_cursor
|
||||
if cursor is None:
|
||||
await self.initialize()
|
||||
await self._refresh_snapshot()
|
||||
return
|
||||
batch = await self.provider.fetch_events(
|
||||
self.instance_uuid,
|
||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
||||
for row in inbox_rows:
|
||||
row.applied_at = now
|
||||
|
||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
||||
async def _apply_accounts(
|
||||
self,
|
||||
session: Any,
|
||||
snapshot: DirectorySnapshot,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, User]:
|
||||
selected: dict[str, DirectoryMember] = {}
|
||||
emails: dict[str, str] = {}
|
||||
for workspace in snapshot.workspaces:
|
||||
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
|
||||
continue
|
||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
||||
if preserve_existing:
|
||||
# A targeted Workspace fetch has no independently monotonic
|
||||
# Account revision. It may create a missing runtime shadow, but
|
||||
# ordered event/snapshot projection remains the only updater of
|
||||
# existing Account identity and status fields.
|
||||
continue
|
||||
if account.projection_revision > snapshot.cursor:
|
||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||
projected_account = self._account_projection(member)
|
||||
|
||||
@@ -635,9 +635,9 @@ class Application:
|
||||
frontend_path = paths.get_frontend_path()
|
||||
|
||||
if not os.path.exists(frontend_path):
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
|
||||
self.logger.warning(
|
||||
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
|
||||
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from .. import stage, app
|
||||
|
||||
# This stage runs before SetupLoggerStage, so ap.logger is still None here;
|
||||
# the module logger falls back to the stderr lastResort handler.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits.
|
||||
_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
_RECOVERY_KEY_LENGTH = 8
|
||||
|
||||
|
||||
@stage.stage_class('GenKeysStage')
|
||||
class GenKeysStage(stage.BootingStage):
|
||||
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
|
||||
ap.instance_config.data['system']['recovery_key'] = ''
|
||||
|
||||
if not ap.instance_config.data['system']['recovery_key']:
|
||||
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
|
||||
# Keep recovery practical to type. Security also requires the reset
|
||||
# endpoint's concurrency-safe quota (five admissions per 15 minutes).
|
||||
ap.instance_config.data['system']['recovery_key'] = ''.join(
|
||||
secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH)
|
||||
)
|
||||
await ap.instance_config.dump_config()
|
||||
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
|
||||
_logger.warning(
|
||||
'Low-entropy legacy recovery key detected (length < 8); '
|
||||
'regenerate system.recovery_key in the configuration file '
|
||||
'with a strong random value (#2392)'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import typing
|
||||
import datetime
|
||||
import time
|
||||
@@ -197,6 +198,41 @@ class TaskWrapper:
|
||||
},
|
||||
}
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
"""Return the stable task projection exposed to API-key callers."""
|
||||
if self.task.cancelled():
|
||||
status = 'cancelled'
|
||||
error = {'type': 'task_cancelled', 'message': 'Task was cancelled'}
|
||||
result = None
|
||||
elif not self.task.done():
|
||||
status = 'running'
|
||||
error = None
|
||||
result = None
|
||||
else:
|
||||
exception = self.assume_exception()
|
||||
if exception is not None:
|
||||
status = 'failed'
|
||||
error = {'type': 'task_failed', 'message': 'Task execution failed'}
|
||||
result = None
|
||||
else:
|
||||
status = 'succeeded'
|
||||
error = None
|
||||
result = self.assume_result()
|
||||
try:
|
||||
json.dumps(result)
|
||||
except (TypeError, ValueError):
|
||||
result = None
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'task_type': self.task_type,
|
||||
'kind': self.kind,
|
||||
'status': status,
|
||||
'error': error,
|
||||
'result': result,
|
||||
'created_at': self.created_at,
|
||||
}
|
||||
|
||||
def cancel(self):
|
||||
self.task.cancel()
|
||||
|
||||
@@ -325,19 +361,20 @@ class AsyncTaskManager:
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
public: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
'tasks': [
|
||||
t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type)
|
||||
and (kind is None or t.kind == kind)
|
||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||
],
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
tasks = [
|
||||
t.to_public_dict() if public else t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type)
|
||||
and (kind is None or t.kind == kind)
|
||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||
]
|
||||
if public:
|
||||
return {'tasks': tasks}
|
||||
return {'tasks': tasks, 'id_index': TaskWrapper._id_index}
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
completed = sum(1 for t in self.tasks if t.task.done())
|
||||
|
||||
@@ -33,6 +33,28 @@ class ModelProvider(Base):
|
||||
)
|
||||
|
||||
|
||||
class CodexCredential(Base):
|
||||
"""Server-only OAuth state. Never joined into provider/model serialization."""
|
||||
|
||||
__tablename__ = 'codex_credentials'
|
||||
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
|
||||
payload = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
|
||||
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
lease_owner = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
lease_until = sqlalchemy.Column(sqlalchemy.Float, nullable=False, default=0)
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_codex_credentials_workspace_provider',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sqlalchemy.Index('ix_codex_credentials_workspace', 'workspace_uuid'),
|
||||
)
|
||||
|
||||
|
||||
class LLMModel(Base):
|
||||
"""LLM model"""
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ class MonitoringSession(Base):
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PasskeyCredential(Base):
|
||||
__tablename__ = 'passkey_credentials'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
nullable=False,
|
||||
default=lambda: str(uuid_lib.uuid4()),
|
||||
)
|
||||
account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
|
||||
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
|
||||
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add isolated server-only Codex credentials and tenant RLS.
|
||||
|
||||
Revision ID: 0022_codex_credentials
|
||||
Revises: 0021_merge_reasoning_config
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0022_codex_credentials'
|
||||
down_revision = '0021_merge_reasoning_config'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Fresh startup creates ORM metadata before running Alembic.
|
||||
if 'codex_credentials' not in sa.inspect(conn).get_table_names():
|
||||
op.create_table(
|
||||
'codex_credentials',
|
||||
sa.Column('provider_uuid', sa.String(255), primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('payload', sa.JSON(), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('lease_owner', sa.String(64), nullable=True),
|
||||
sa.Column('lease_until', sa.Float(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_codex_credentials_workspace_provider',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
)
|
||||
op.create_index('ix_codex_credentials_workspace', 'codex_credentials', ['workspace_uuid'])
|
||||
if conn.dialect.name == 'postgresql':
|
||||
op.execute('ALTER TABLE codex_credentials ENABLE ROW LEVEL SECURITY')
|
||||
op.execute('ALTER TABLE codex_credentials FORCE ROW LEVEL SECURITY')
|
||||
op.execute('DROP POLICY IF EXISTS langbot_workspace_isolation ON codex_credentials')
|
||||
expression = "workspace_uuid::text = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
|
||||
op.execute(
|
||||
f'CREATE POLICY langbot_workspace_isolation ON codex_credentials '
|
||||
f'FOR ALL USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('codex_credentials')
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Scope monitoring sessions by bot without changing runtime session IDs.
|
||||
|
||||
Revision ID: 0023_bot_scoped_sessions
|
||||
Revises: 0022_codex_credentials
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
||||
revision = '0023_bot_scoped_sessions'
|
||||
down_revision = '0022_codex_credentials'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = 'monitoring_sessions'
|
||||
_KEY = ['workspace_uuid', 'bot_id', 'session_id']
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if _TABLE not in inspector.get_table_names():
|
||||
return
|
||||
pk = inspector.get_pk_constraint(_TABLE)
|
||||
if pk['constrained_columns'] == _KEY:
|
||||
return
|
||||
# PostgreSQL alters in place, retaining indexes, grants, policies and RLS.
|
||||
# SQLite batch reflection retains all existing indexes and foreign keys.
|
||||
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
|
||||
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
|
||||
batch.create_primary_key(f'pk_{_TABLE}', _KEY)
|
||||
|
||||
metadata = sa.MetaData()
|
||||
sessions = sa.Table(_TABLE, metadata, autoload_with=conn)
|
||||
messages = sa.Table('monitoring_messages', metadata, autoload_with=conn)
|
||||
m = messages.c
|
||||
collisions = (
|
||||
sa.select(m.workspace_uuid, m.session_id)
|
||||
.group_by(m.workspace_uuid, m.session_id)
|
||||
.having(sa.func.count(sa.distinct(m.bot_id)) > 1)
|
||||
.subquery()
|
||||
)
|
||||
partition = [m.workspace_uuid, m.bot_id, m.session_id]
|
||||
# Repair only demonstrable collisions. Retention may have removed earlier
|
||||
# evidence; these summaries describe surviving messages, never invented text.
|
||||
ranked = (
|
||||
sa.select(
|
||||
*[m[name] for name in _KEY],
|
||||
m.bot_name,
|
||||
m.pipeline_id,
|
||||
m.pipeline_name,
|
||||
m.platform,
|
||||
m.user_id,
|
||||
m.user_name,
|
||||
sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0))
|
||||
.over(partition_by=partition)
|
||||
.label('message_count'),
|
||||
sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'),
|
||||
sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'),
|
||||
sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'),
|
||||
)
|
||||
.join(
|
||||
collisions,
|
||||
sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
columns = _KEY + [
|
||||
'bot_name',
|
||||
'pipeline_id',
|
||||
'pipeline_name',
|
||||
'platform',
|
||||
'user_id',
|
||||
'user_name',
|
||||
'message_count',
|
||||
'start_time',
|
||||
'last_activity',
|
||||
'is_active',
|
||||
]
|
||||
select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1)
|
||||
insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert
|
||||
statement = insert(sessions).from_select(columns, select)
|
||||
conn.execute(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=_KEY,
|
||||
set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _TABLE not in sa.inspect(conn).get_table_names():
|
||||
return
|
||||
collisions = conn.execute(
|
||||
sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1')
|
||||
).first()
|
||||
if collisions:
|
||||
raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records')
|
||||
pk = sa.inspect(conn).get_pk_constraint(_TABLE)
|
||||
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
|
||||
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
|
||||
batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id'])
|
||||
@@ -0,0 +1,54 @@
|
||||
"""add passkey credentials table
|
||||
|
||||
Revision ID: 0024_passkey_credentials
|
||||
Revises: 0023_bot_scoped_sessions
|
||||
Create Date: 2026-09-12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0024_passkey_credentials'
|
||||
down_revision = '0023_bot_scoped_sessions'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE_NAME = 'passkey_credentials'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('uuid', sa.String(36), nullable=False),
|
||||
sa.Column(
|
||||
'account_uuid',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('credential_id', sa.String(255), nullable=False),
|
||||
sa.Column('public_key', sa.Text(), nullable=False),
|
||||
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('aaguid', sa.String(64), nullable=True),
|
||||
sa.Column('transports', sa.String(255), nullable=True),
|
||||
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
|
||||
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
|
||||
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -62,6 +62,8 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'codex_credentials',
|
||||
'passkey_credentials',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
|
||||
@@ -51,6 +51,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'codex_credentials': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
@@ -206,6 +207,8 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None]
|
||||
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'coalesce': sqlalchemy.sql.functions.coalesce,
|
||||
'count': sqlalchemy.sql.functions.count,
|
||||
'min': sqlalchemy.sql.functions.min,
|
||||
'max': sqlalchemy.sql.functions.max,
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
@@ -852,7 +855,30 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
|
||||
self._require_owner_task()
|
||||
self._enter_internal_access()
|
||||
try:
|
||||
await transaction.commit()
|
||||
# Retain the actual connection before COMMIT: after a failed SQLite
|
||||
# COMMIT the logical transaction is inactive, but the DBAPI writer
|
||||
# can still hold PENDING/RESERVED locks. Session.close()/rollback()
|
||||
# alone can then return that poisoned connection to the pool.
|
||||
connection = await super().connection()
|
||||
try:
|
||||
await transaction.commit()
|
||||
except BaseException as exc:
|
||||
cleanup = asyncio.create_task(connection.invalidate())
|
||||
# Invalidation does not access the task-owned Session. Shield
|
||||
# physical cleanup, including against repeated cancellation,
|
||||
# before the owner closes the Session and releases its scope.
|
||||
while not cleanup.done():
|
||||
try:
|
||||
await asyncio.shield(cleanup)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except BaseException:
|
||||
break
|
||||
try:
|
||||
cleanup.result()
|
||||
except BaseException as cleanup_error:
|
||||
exc.add_note(f'Failed to invalidate transaction connection: {cleanup_error!r}')
|
||||
raise
|
||||
finally:
|
||||
self._exit_internal_access()
|
||||
|
||||
@@ -1369,6 +1395,7 @@ class TenantUnitOfWork:
|
||||
state.mark_rollback_only(exc_value)
|
||||
rollback_only = state.rollback_only
|
||||
committed = False
|
||||
transaction_error: BaseException | None = None
|
||||
try:
|
||||
if exc_type is None and not rollback_only:
|
||||
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
||||
@@ -1381,6 +1408,9 @@ class TenantUnitOfWork:
|
||||
_UOW_SESSION_CONTROL_CAPABILITY,
|
||||
transaction,
|
||||
)
|
||||
except BaseException as exc:
|
||||
transaction_error = exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
if self._active_transaction is not None and self._context_token is not None:
|
||||
@@ -1388,6 +1418,10 @@ class TenantUnitOfWork:
|
||||
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
||||
_UOW_SESSION_CONTROL_CAPABILITY
|
||||
)
|
||||
except BaseException as cleanup_error:
|
||||
if transaction_error is None:
|
||||
raise
|
||||
transaction_error.add_note(f'Failed to close transaction Session: {cleanup_error!r}')
|
||||
finally:
|
||||
if self._database_operation_token is not None:
|
||||
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
||||
|
||||
@@ -79,6 +79,7 @@ class MonitoringHelper:
|
||||
session_updated = await ap.monitoring_service.update_session_activity(
|
||||
get_query_execution_context(query),
|
||||
session_id,
|
||||
bot_id=bot_id,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||
en: https://link.langbot.app/en/platforms/dingtalk
|
||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/discord
|
||||
en: https://link.langbot.app/en/platforms/discord
|
||||
ja: https://link.langbot.app/ja/platforms/discord
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
|
||||
@@ -18,9 +18,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/kook
|
||||
en: https://link.langbot.app/en/platforms/kook
|
||||
ja: https://link.langbot.app/ja/platforms/kook
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
||||
|
||||
|
||||
def _lark_final_layout_texts(
|
||||
*,
|
||||
resume_from: bool,
|
||||
text_message: str,
|
||||
pre_pause_cached: str | None,
|
||||
resume_cached: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (main_text, resume_placeholder_text) for the final card update.
|
||||
|
||||
Non-resume round: the full reply belongs in the main streaming element
|
||||
only — also rendering the resume placeholder duplicates the reply, since
|
||||
both hold the same accumulated text. Resume round (Dify HITL): keep the
|
||||
pre-pause text in the main element and the resumed text in the
|
||||
placeholder, as they are distinct segments.
|
||||
"""
|
||||
if resume_from:
|
||||
# An empty pre-pause cache is valid (Dify paused before emitting any
|
||||
# text); only a missing entry (None) falls back to the full text.
|
||||
main_text = text_message if pre_pause_cached is None else pre_pause_cached
|
||||
return main_text, resume_cached
|
||||
return text_message, ''
|
||||
|
||||
|
||||
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||
field_type = _dify_field_type(field)
|
||||
if field_type == 'file':
|
||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||
else:
|
||||
# Normal finish: keep pre-pause + resume content visible,
|
||||
# remove buttons/notice, drop the resume placeholder.
|
||||
# Normal finish: remove buttons/notice and finalize the card.
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=resume_from,
|
||||
text_message=text_message,
|
||||
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||
resume_cached=resume_cached,
|
||||
)
|
||||
await self._update_card_layout(
|
||||
card_id=card_id,
|
||||
message_source=message_source,
|
||||
text_message=pre_pause,
|
||||
text_message=main_text,
|
||||
sequence=final_seq,
|
||||
form_data=None,
|
||||
notice_text=selected_notice if resume_from else '',
|
||||
resume_placeholder_text=resume_cached,
|
||||
resume_placeholder_text=resume_text,
|
||||
)
|
||||
self._drop_card_state(card_id)
|
||||
self.card_id_dict.pop(message_id, None)
|
||||
|
||||
@@ -19,9 +19,9 @@ spec:
|
||||
- china
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/lark
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||
config:
|
||||
- name: domain
|
||||
label:
|
||||
|
||||
@@ -22,9 +22,9 @@ spec:
|
||||
categories:
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/line
|
||||
en: https://link.langbot.app/en/platforms/line
|
||||
ja: https://link.langbot.app/ja/platforms/line
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||
en: https://langbot.app/docs/en/usage/platforms/line
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||
continue
|
||||
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||
|
||||
# Cancel existing tasks
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import typing
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import aiohttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
_MATTERMOST_MAX_POST_LENGTH = 16_383
|
||||
_MENTION_BOUNDARY = r'(?<![\w.-])@{username}(?![\w.-])'
|
||||
|
||||
|
||||
def _normalize_server_url(server_url: str) -> str:
|
||||
"""Return a validated Mattermost server URL without a trailing slash."""
|
||||
|
||||
url = server_url.strip().rstrip('/')
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
|
||||
raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL')
|
||||
return url
|
||||
|
||||
|
||||
def _websocket_url(server_url: str) -> str:
|
||||
parsed = urlsplit(server_url)
|
||||
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
|
||||
return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', ''))
|
||||
|
||||
|
||||
class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Translate Mattermost post text to and from LangBot message chains."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
|
||||
parts: list[str] = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
parts.append(component.text)
|
||||
elif isinstance(component, platform_message.Image) and component.url:
|
||||
# Mattermost renders image URLs in Markdown messages.
|
||||
parts.append(component.url)
|
||||
elif isinstance(component, platform_message.File) and component.url:
|
||||
parts.append(component.url)
|
||||
return ''.join(parts)
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain:
|
||||
text = str(post.get('message') or '')
|
||||
components: list[typing.Any] = [
|
||||
platform_message.Source(
|
||||
id=str(post.get('id') or ''),
|
||||
time=float(post.get('create_at') or 0) / 1000,
|
||||
)
|
||||
]
|
||||
if bot_username:
|
||||
mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE)
|
||||
if mention_pattern.search(text):
|
||||
components.append(platform_message.At(target=bot_username))
|
||||
text = mention_pattern.sub('', text).strip()
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
post: dict,
|
||||
channel: dict,
|
||||
sender_name: str,
|
||||
bot_username: str,
|
||||
) -> platform_events.MessageEvent:
|
||||
message_chain = await MattermostMessageConverter.target2yiri(post, bot_username)
|
||||
timestamp = float(post.get('create_at') or 0) / 1000
|
||||
sender_id = str(post.get('user_id') or '')
|
||||
channel_type = channel.get('type')
|
||||
|
||||
if channel_type == 'D':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object={'post': post, 'channel': channel},
|
||||
)
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=sender_id,
|
||||
member_name=sender_name or sender_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=str(post.get('channel_id') or ''),
|
||||
name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''),
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object={'post': post, 'channel': channel},
|
||||
)
|
||||
|
||||
|
||||
class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""Mattermost Bot Account adapter using the v4 REST and WebSocket APIs."""
|
||||
|
||||
server_url: str = ''
|
||||
access_token: str = ''
|
||||
session: aiohttp.ClientSession | None = None
|
||||
listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
||||
channel_cache: dict[str, dict] = {}
|
||||
stream_post_ids: dict[str, str] = {}
|
||||
bot_username: str = ''
|
||||
_running: bool = False
|
||||
|
||||
message_converter: MattermostMessageConverter = MattermostMessageConverter()
|
||||
event_converter: MattermostEventConverter = MattermostEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
server_url = _normalize_server_url(str(config.get('server_url') or ''))
|
||||
access_token = str(config.get('access_token') or '').strip()
|
||||
if not access_token:
|
||||
raise ValueError('Mattermost adapter requires an access_token')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
server_url=server_url,
|
||||
access_token=access_token,
|
||||
bot_account_id='',
|
||||
session=None,
|
||||
listeners={},
|
||||
channel_cache={},
|
||||
stream_post_ids={},
|
||||
bot_username='',
|
||||
_running=False,
|
||||
)
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self.session is None or self.session.closed:
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers={'Authorization': f'Bearer {self.access_token}'},
|
||||
raise_for_status=False,
|
||||
)
|
||||
return self.session
|
||||
|
||||
async def _api_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> dict:
|
||||
session = await self._get_session()
|
||||
async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response:
|
||||
raw_body = await response.text()
|
||||
if response.status >= 400:
|
||||
# Mattermost returns a useful JSON error, but never include request headers/tokens in errors.
|
||||
try:
|
||||
error = json.loads(raw_body).get('message', raw_body)
|
||||
except json.JSONDecodeError:
|
||||
error = raw_body
|
||||
raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}')
|
||||
if not raw_body:
|
||||
return {}
|
||||
return json.loads(raw_body)
|
||||
|
||||
async def _load_bot_identity(self) -> None:
|
||||
user = await self._api_request('GET', '/users/me')
|
||||
self.bot_account_id = str(user.get('id') or '')
|
||||
self.bot_username = str(user.get('username') or '')
|
||||
if not self.bot_account_id:
|
||||
raise RuntimeError('Mattermost API did not return a bot user ID')
|
||||
|
||||
async def _get_channel(self, channel_id: str) -> dict:
|
||||
if channel_id not in self.channel_cache:
|
||||
self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}')
|
||||
return self.channel_cache[channel_id]
|
||||
|
||||
async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict:
|
||||
if not text:
|
||||
return {}
|
||||
if len(text) > _MATTERMOST_MAX_POST_LENGTH:
|
||||
raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters')
|
||||
payload = {'channel_id': channel_id, 'message': text}
|
||||
if root_id:
|
||||
payload['root_id'] = root_id
|
||||
return await self._api_request('POST', '/posts', payload=payload)
|
||||
|
||||
async def _get_direct_channel_id(self, user_id: str) -> str:
|
||||
if not self.bot_account_id:
|
||||
await self._load_bot_identity()
|
||||
channel = await self._api_request(
|
||||
'POST',
|
||||
'/channels/direct',
|
||||
payload={'user_ids': [self.bot_account_id, user_id]},
|
||||
)
|
||||
channel_id = str(channel.get('id') or '')
|
||||
if not channel_id:
|
||||
raise RuntimeError('Mattermost did not return a direct-message channel ID')
|
||||
self.channel_cache[channel_id] = channel
|
||||
return channel_id
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
if target_type not in {'person', 'group'}:
|
||||
raise ValueError("Mattermost target_type must be 'person' or 'group'")
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
channel_id = str(target_id)
|
||||
if target_type == 'person':
|
||||
channel_id = await self._get_direct_channel_id(channel_id)
|
||||
await self._post_message(channel_id, text)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
source = await self.event_converter.yiri2target(message_source)
|
||||
post = source['post']
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
# A message received inside a Mattermost thread must remain in that thread. When
|
||||
# quote_origin is requested, make the response a reply to the source root post.
|
||||
root_id = str(post.get('root_id') or '')
|
||||
if quote_origin and not root_id:
|
||||
root_id = str(post.get('id') or '')
|
||||
await self._post_message(str(post['channel_id']), text, root_id)
|
||||
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
source = await self.event_converter.yiri2target(event)
|
||||
post = source['post']
|
||||
root_id = str(post.get('root_id') or post.get('id') or '')
|
||||
created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id)
|
||||
if created.get('id'):
|
||||
self.stream_post_ids[str(message_id)] = str(created['id'])
|
||||
return True
|
||||
return False
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
response_id = str(bot_message.resp_message_id)
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
if not text:
|
||||
return
|
||||
|
||||
post_id = self.stream_post_ids.get(response_id)
|
||||
if post_id:
|
||||
await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text})
|
||||
else:
|
||||
source = await self.event_converter.yiri2target(message_source)
|
||||
post = source['post']
|
||||
root_id = str(post.get('root_id') or '')
|
||||
if quote_origin and not root_id:
|
||||
root_id = str(post.get('id') or '')
|
||||
created = await self._post_message(str(post['channel_id']), text, root_id)
|
||||
post_id = str(created.get('id') or '')
|
||||
if post_id:
|
||||
self.stream_post_ids[response_id] = post_id
|
||||
|
||||
if is_final and getattr(bot_message, 'tool_calls', None) is None:
|
||||
self.stream_post_ids.pop(response_id, None)
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return bool(self.config.get('enable_stream_reply', True))
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def _dispatch_post(self, payload: dict) -> None:
|
||||
data = payload.get('data') or {}
|
||||
try:
|
||||
post = json.loads(data.get('post') or '{}')
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
await self.logger.error('Mattermost received a posted event with an invalid post payload')
|
||||
return
|
||||
|
||||
if not post or str(post.get('user_id') or '') == self.bot_account_id:
|
||||
return
|
||||
channel_id = str(post.get('channel_id') or '')
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
try:
|
||||
channel = await self._get_channel(channel_id)
|
||||
event = await self.event_converter.target2yiri(
|
||||
post,
|
||||
channel,
|
||||
str(data.get('sender_name') or post.get('user_id') or ''),
|
||||
self.bot_username,
|
||||
)
|
||||
callback = self.listeners.get(type(event))
|
||||
if callback:
|
||||
result = callback(event, self)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as exc:
|
||||
await self.logger.error(f'Error handling Mattermost post: {exc}')
|
||||
|
||||
async def _run_websocket_once(self) -> None:
|
||||
session = await self._get_session()
|
||||
async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket:
|
||||
await websocket.send_json(
|
||||
{
|
||||
'seq': 1,
|
||||
'action': 'authentication_challenge',
|
||||
'data': {'token': self.access_token},
|
||||
}
|
||||
)
|
||||
async for message in websocket:
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
payload = json.loads(message.data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if payload.get('event') == 'posted':
|
||||
await self._dispatch_post(payload)
|
||||
elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}:
|
||||
break
|
||||
|
||||
async def run_async(self):
|
||||
self._running = True
|
||||
await self._load_bot_identity()
|
||||
await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})')
|
||||
|
||||
retry_delay = 1
|
||||
while self._running:
|
||||
try:
|
||||
await self._run_websocket_once()
|
||||
retry_delay = 1
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if self._running:
|
||||
await self.logger.error(f'Mattermost WebSocket disconnected: {exc}')
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 30)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._running = False
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
return True
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Artwork" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140"><defs><style>.cls-1{fill:#1e325c;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M111.11,13.36l.74,14.86c12.04,13.3,16.8,32.15,10.81,49.86-8.95,26.44-38.46,40.33-65.92,31.04-27.46-9.29-42.45-38.26-33.5-64.7,6.01-17.77,21.32-29.87,39.05-33.07L71.87.03C41.99-.77,13.8,17.77,3.72,47.55c-12.4,36.6,7.24,76.33,43.85,88.73,36.6,12.4,76.33-7.24,88.73-43.85,10.07-29.74-1-61.55-25.14-79.07h-.03Z"/><path class="cls-1" d="M93.95,57.21l-.51-20.77-.41-11.95-.28-10.35s.07-4.99-.11-6.16c-.03-.25-.11-.44-.21-.62,0-.03-.02-.05-.03-.07,0-.02-.03-.05-.03-.07-.2-.33-.49-.59-.89-.72s-.8-.1-1.17.05h-.02s-.08.03-.13.07c-.16.08-.34.2-.51.36-.85.82-3.84,4.83-3.84,4.83l-6.5,8.06-7.59,9.25-13.02,16.19s-5.98,7.46-4.65,16.64c1.31,9.18,8.15,13.65,13.43,15.44,5.29,1.79,13.43,2.38,20.05-4.11,6.62-6.49,6.4-16.04,6.4-16.04l.02-.02Z"/></svg>
|
||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: mattermost
|
||||
label:
|
||||
en_US: Mattermost
|
||||
zh_Hans: Mattermost
|
||||
zh_Hant: Mattermost
|
||||
ja_JP: Mattermost
|
||||
th_TH: Mattermost
|
||||
vi_VN: Mattermost
|
||||
es_ES: Mattermost
|
||||
icon: mattermost.svg
|
||||
description:
|
||||
en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this.
|
||||
zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。
|
||||
zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。
|
||||
ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。
|
||||
th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป
|
||||
vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn.
|
||||
es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio.
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
- popular
|
||||
config:
|
||||
- name: server_url
|
||||
label:
|
||||
en_US: Mattermost Server URL
|
||||
zh_Hans: Mattermost 服务器地址
|
||||
zh_Hant: 位址伺服器 Mattermost
|
||||
ja_JP: Mattermost サーバー URL
|
||||
th_TH: URL เซิร์ฟเวอร์ Mattermost
|
||||
vi_VN: URL máy chủ Mattermost
|
||||
es_ES: URL del servidor Mattermost
|
||||
description:
|
||||
en_US: The base URL of the Mattermost server, for example https://mattermost.example.com
|
||||
zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: access_token
|
||||
label:
|
||||
en_US: Bot Access Token
|
||||
zh_Hans: Bot 访问令牌
|
||||
zh_Hant: Bot 存取權杖
|
||||
ja_JP: Bot アクセストークン
|
||||
th_TH: โทเค็นการเข้าถึงของบอต
|
||||
vi_VN: Mã truy cập Bot
|
||||
es_ES: Token de acceso del bot
|
||||
description:
|
||||
en_US: The personal access token generated for the Mattermost Bot Account
|
||||
zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable_stream_reply
|
||||
label:
|
||||
en_US: Enable Stream Reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
ja_JP: ストリーミング返信を有効化
|
||||
th_TH: เปิดใช้งานการตอบกลับแบบสตรีม
|
||||
vi_VN: Bật phản hồi luồng
|
||||
es_ES: Activar respuesta en streaming
|
||||
description:
|
||||
en_US: Update a Mattermost post while LangBot generates a response
|
||||
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
execution:
|
||||
python:
|
||||
path: ./mattermost.py
|
||||
attr: MattermostAdapter
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||
en: https://link.langbot.app/en/platforms/officialaccount
|
||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
token=config.get('token', ''),
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user