Compare commits

..

5 Commits

Author SHA1 Message Date
dadachann 8e3d541876 docs(readme): remove retired public demo from all languages 2026-09-14 17:08:31 +00:00
sheetung 60bb67f025 Fnos packaging (#2524)
* feat(packaging): add fnOS FPK packaging and CI workflow

Add packaging/fnos/ shell (manifest, lifecycle cmd scripts, install/
upgrade/uninstall wizards, desktop entry, EULA) plus in-repo build
script. A release now auto-builds langbot-<tag>-fnos.fpk via
.github/workflows/build-fnos-fpk.yaml, uploaded to the release assets.

* ci(fnos): skip release upload on manual dispatch

github.event.release.tag_name is empty when triggered via
workflow_dispatch, causing 'gh release upload' to fail with
'requires at least 2 arg(s)'. Restrict the step to release events.

* ci(fnos): normalize release asset name

Strip a trailing -fnos from the tag-derived version before appending
the suffix, avoiding langbot-<v>-fnos-fnos.fpk when the tag itself
already carries -fnos.

* ci(fnos): use release tag version for auto build, manifest for manual

Auto build (release/tag) reads version from tag like other release
workflows; build.sh strips the v prefix when injecting into manifest.
Manual dispatch falls back to the version maintained in manifest.

* feat(fnos): add post-install deployment notice to install wizard

Last wizard step now informs users that first startup takes about
5-10 minutes for dependency setup before the web UI is ready.

* docs(fnos): add packaging directory README

* refactor(fnos): rename app from ai.langbot to langbot

Rename appname, desktop entry, data share, build artifacts, and all
references from ai.langbot to langbot across packaging files.

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-14 00:31:19 +08:00
Type_rBody cbe6844342 Merge pull request #2541 from hedging8563/codex/refresh-tokenlab-logo-20260913
chore(brand): refresh TokenLab logo
2026-09-13 22:18:08 +08:00
hedging8563 940895c541 chore(brand): refresh TokenLab logo 2026-09-13 21:24:38 +08:00
Hyu 9b130680ca ci(discord): announce published stable releases via webhook (#2539)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-13 00:39:21 +08:00
55 changed files with 2001 additions and 1692 deletions
+111
View File
@@ -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.
+162
View File
@@ -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())
+427
View File
@@ -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',
'v.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()
+78
View File
@@ -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"
+64
View File
@@ -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
+9
View File
@@ -60,3 +60,12 @@ 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
-11
View File
@@ -93,17 +93,6 @@ docker compose --profile all up -d
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
---
## Supported Platforms
| Platform | Status | Notes |
-10
View File
@@ -93,16 +93,6 @@ docker compose --profile all up -d
---
## 在线演示
**立即体验:** https://demo.langbot.dev/
- 邮箱:`demo@langbot.app`
- 密码:`langbot123456`
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
---
## 支持的平台
| 平台 | 状态 | 备注 |
-10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## 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.*
---
## Plataformas Soportadas
| Plataforma | Estado | Notas |
-10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## 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.*
---
## Plateformes Supportées
| Plateforme | Statut | Notes |
-10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## ライブデモ
**今すぐ試す:** https://demo.langbot.dev/
- メール: `demo@langbot.app`
- パスワード: `langbot123456`
*注意: 公開デモ環境です。機密情報を入力しないでください。*
---
## 対応プラットフォーム
| プラットフォーム | ステータス | 備考 |
-10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## 라이브 데모
**지금 체험:** https://demo.langbot.dev/
- 이메일: `demo@langbot.app`
- 비밀번호: `langbot123456`
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
---
## 지원 플랫폼
| 플랫폼 | 상태 | 비고 |
-10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## Демо
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Пароль: `langbot123456`
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
---
## Поддерживаемые платформы
| Платформа | Статус | Примечания |
-10
View File
@@ -94,16 +94,6 @@ docker compose --profile all up -d
---
## 線上演示
**立即體驗:** https://demo.langbot.dev/
- 信箱:`demo@langbot.app`
- 密碼:`langbot123456`
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
---
## 支援的平台
| 平台 | 狀態 | 備註 |
-10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## 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.*
---
## Nền tảng được hỗ trợ
| Nền tảng | Trạng thái | Ghi chú |
+145
View File
@@ -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
+78
View File
@@ -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
}
+13
View File
@@ -0,0 +1,13 @@
{
".url": {
"langbot.main": {
"title": "LangBot",
"icon": "images/icon-{0}.png",
"type": "url",
"protocol": "http",
"port": "${wizard_port}",
"url": "/",
"allUsers": true
}
}
}
+180
View File
@@ -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
+6
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# cmd/config_init - pre-config hook
exit 0
+149
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# cmd/install_init - pre-install hook
# Nothing special to do before extraction.
exit 0
+194
View File
@@ -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
+21
View File
@@ -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
+20
View File
@@ -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
+77
View File
@@ -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
+20
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"defaults": {
"run-as": "root"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"data-share": {
"shares": [
{
"name": "langbot/data"
}
]
}
}
+16
View File
@@ -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 能力
+47
View File
@@ -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 分钟完成依赖部署与初始化,部署完成后即可打开网页端使用。"
}
]
}
]
+17
View File
@@ -0,0 +1,17 @@
[
{
"stepTitle": "数据保留",
"items": [
{
"type": "radio",
"field": "wizard_keep_data",
"label": "是否保留 LangBot 数据(插件、配置、日志)",
"initValue": "yes",
"options": [
{ "label": "保留数据(重新安装后可继续使用)", "value": "yes" },
{ "label": "彻底删除全部数据", "value": "no" }
]
}
]
}
]
+38
View File
@@ -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": "端口号长度不正确" }
]
}
]
}
]
@@ -1,10 +1,3 @@
"""Account, authentication, passkey and TOTP HTTP routes.
Exposes the unauthenticated login/recovery surface as well as the authenticated
account-management, passkey (WebAuthn) and TOTP second-factor endpoints under
``/api/v1/user``.
"""
from __future__ import annotations
import quart
@@ -22,7 +15,6 @@ from .....entity.errors import account as account_errors
from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
from ...service.totp import TotpAlreadyEnabledError, TotpInvalidCodeError, TotpNotEnabledError
# 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
@@ -54,10 +46,7 @@ def _admit_reset_attempt(now: float) -> bool:
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
"""``/api/v1/user`` routes for accounts, auth, passkeys and TOTP."""
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
"""Validate a Space OAuth redirect URI against the expected callback shape."""
parsed = urlsplit(redirect_uri)
if (
parsed.scheme not in {'http', 'https'}
@@ -78,11 +67,7 @@ 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]:
"""Resolve the WebAuthn origin and relying-party ID for a request."""
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', '')
@@ -95,21 +80,14 @@ class UserRouterGroup(group.RouterGroup):
parsed = urlsplit(origin)
rp_id = parsed.hostname or 'localhost'
if parsed.scheme and parsed.netloc:
clean_origin = f'{parsed.scheme}://{parsed.netloc}'
else:
clean_origin = origin.rstrip('/')
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:
"""Register every ``/api/v1/user`` route on this router group."""
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Report initialization state, or create the first account (POST)."""
if quart.request.method == 'GET':
initialized = await self.ap.user_service.is_initialized()
return self.success(data={'initialized': initialized})
return self.success(data={'initialized': await self.ap.user_service.is_initialized()})
if await self.ap.user_service.is_initialized():
return self.fail(1, 'System already initialized')
@@ -130,56 +108,27 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Authenticate a local Account, requiring a TOTP factor when enabled."""
deployment = getattr(self.ap, 'deployment', None)
if getattr(deployment, 'mode', 'oss') == 'cloud':
return self.http_status(
403,
'password_login_disabled',
'Password login is disabled on LangBot Cloud',
)
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
json_data = await quart.request.json
user_email = json_data['user']
try:
token = await self.ap.user_service.authenticate(user_email, json_data['password'])
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password')
except ValueError as e:
return self.fail(1, str(e))
# Second factor: an enabled TOTP credential makes the password alone
# insufficient. The client retries the same request with a code.
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid):
totp_code = json_data.get('totp_code')
recovery_code = json_data.get('recovery_code')
verified = False
if totp_code:
verified = await self.ap.totp_service.verify_for_account(
user_obj.uuid,
str(totp_code),
)
elif recovery_code:
verified = await self.ap.totp_service.redeem_recovery_code(
user_obj.uuid,
str(recovery_code),
)
if not verified:
return self.http_status(401, 'totp_required', 'TOTP verification required')
return self.success(data={'token': token})
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Issue a fresh user token for an already-authenticated Account."""
token = await self.ap.user_service.generate_jwt_token(account)
return self.success(data={'token': token})
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Reset a password using the recovery key, TOTP, or a recovery code."""
# 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.
@@ -189,11 +138,7 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
user_email = json_data['user']
# Recovery accepts either the instance recovery key, or (for accounts
# that enrolled one) a TOTP code or a one-time TOTP recovery code.
recovery_key = json_data.get('recovery_key')
totp_code = json_data.get('totp_code')
recovery_code = json_data.get('recovery_code')
recovery_key = json_data['recovery_key']
new_password = json_data['new_password']
# hard sleep 3s for security
@@ -207,39 +152,19 @@ class UserRouterGroup(group.RouterGroup):
if user_obj is None:
return self.http_status(400, -1, 'User not found')
if totp_code or recovery_code:
if not await self.ap.totp_service.is_enabled(user_obj.uuid):
return self.http_status(
403,
'totp_not_enabled',
'TOTP is not enabled for this account',
)
if totp_code:
authorized = await self.ap.totp_service.verify_for_account(
user_obj.uuid,
str(totp_code),
)
else:
authorized = await self.ap.totp_service.redeem_recovery_code(
user_obj.uuid,
str(recovery_code),
)
if not authorized:
return self.http_status(403, 'totp_invalid_code', 'Invalid TOTP code')
else:
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
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')
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
@@ -247,7 +172,6 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Change the current Account password after verifying the old one."""
# Check if password change is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -261,11 +185,7 @@ class UserRouterGroup(group.RouterGroup):
new_password = json_data['new_password']
try:
await self.ap.user_service.change_password(
user_email,
current_password,
new_password,
)
await self.ap.user_service.change_password(user_email, current_password, new_password)
except argon2.exceptions.VerifyMismatchError:
return self.http_status(400, -1, 'Current password is incorrect')
except ValueError as e:
@@ -289,8 +209,7 @@ class UserRouterGroup(group.RouterGroup):
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
if launch_workspace_uuid:
deployment = getattr(self.ap, 'deployment', None)
if not getattr(deployment, 'multi_workspace_enabled', False):
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
return self.fail(1, 'Space launch requires Cloud mode')
try:
uuid.UUID(launch_workspace_uuid)
@@ -307,11 +226,7 @@ class UserRouterGroup(group.RouterGroup):
except ValueError as e:
return self.fail(1, str(e))
@self.route(
'/space/bind-authorize-url',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
)
@self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext) -> str:
"""Issue an account-bound, one-time Space OAuth redirect."""
redirect_uri = quart.request.args.get('redirect_uri', '')
@@ -357,24 +272,19 @@ class UserRouterGroup(group.RouterGroup):
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',
)
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
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
workspace_created_ats: dict[str, int] = {}
deployment = getattr(self.ap, 'deployment', None)
if not workspace_uuids and getattr(deployment, 'mode', 'oss') != 'cloud':
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
binding = await self.ap.workspace_service.get_execution_binding()
workspace_uuids = [binding.workspace_uuid]
workspace_created_at = binding.workspace_created_at
if workspace_created_at is not None:
if workspace_created_at.tzinfo is None:
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
created_at_epoch = int(workspace_created_at.timestamp())
workspace_created_ats[binding.workspace_uuid] = created_at_epoch
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
token_data = await self.ap.space_service.exchange_oauth_code(
code,
workspace_uuids,
@@ -389,20 +299,14 @@ class UserRouterGroup(group.RouterGroup):
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
deployment = getattr(self.ap, 'deployment', None)
cloud_mode = getattr(deployment, 'mode', 'oss') == 'cloud'
launch_mismatch = launch_workspace_uuid != cloud_workspace_uuid
if cloud_mode and launch_workspace_uuid and launch_mismatch:
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',
)
projection_service = self.ap.directory_projection_service
await projection_service.reconcile_workspaces((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.
@@ -412,15 +316,12 @@ class UserRouterGroup(group.RouterGroup):
if target_workspace_uuid:
try:
collab_service = self.ap.workspace_collaboration_service
access = await collab_service.resolve_account_workspace(
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid,
target_workspace_uuid,
)
except Exception:
self.ap.logger.warning(
'Rejected Space OAuth launch for unauthorized Workspace',
)
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
return self.fail(1, 'Space OAuth failed')
return self.success(
data={
@@ -455,7 +356,6 @@ class UserRouterGroup(group.RouterGroup):
'user': account.user,
'account_type': account.account_type,
'has_password': bool(account.password and account.password.strip()),
'totp_enabled': await self.ap.totp_service.is_enabled(account.uuid),
}
)
@@ -508,7 +408,6 @@ class UserRouterGroup(group.RouterGroup):
capabilities['invitation_registration_enabled'] = not cloud_mode
capabilities['passkey_login_enabled'] = True
capabilities['passkey_supported'] = True
capabilities['totp_supported'] = True
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -599,11 +498,7 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
raise
@self.route(
'/passkey/register/options',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
@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(
@@ -620,9 +515,7 @@ class UserRouterGroup(group.RouterGroup):
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
user_service = self.ap.user_service
reg_options = user_service.generate_passkey_registration_options
options, challenge_token = await reg_options(
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
account_uuid=user_obj.uuid,
rp_id=rp_id,
origin=origin,
@@ -632,11 +525,7 @@ class UserRouterGroup(group.RouterGroup):
except Exception as e:
return self.fail(1, str(e))
@self.route(
'/passkey/register/verify',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
@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(
@@ -681,9 +570,7 @@ class UserRouterGroup(group.RouterGroup):
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
user_service = self.ap.user_service
auth_options = user_service.generate_passkey_authentication_options
options, challenge_token = await auth_options(
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
rp_id=rp_id,
origin=origin,
email=email,
@@ -739,11 +626,7 @@ class UserRouterGroup(group.RouterGroup):
]
)
@self.route(
'/passkey/<passkey_uuid>',
methods=['PATCH'],
auth_type=group.AuthType.USER_TOKEN,
)
@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(
@@ -770,11 +653,7 @@ class UserRouterGroup(group.RouterGroup):
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,
)
@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(
@@ -795,160 +674,6 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(404, -1, 'Passkey not found')
return self.success()
@self.route('/totp/check', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Report whether TOTP is enabled for a given Account (unauthenticated).
Used by the password-recovery page to decide whether the TOTP and
recovery-code verification methods are selectable. Only the boolean
capability is disclosed; no account details leak.
"""
if not await self.ap.user_service.is_initialized():
return self.http_status(400, -1, 'System not initialized')
json_data = await quart.request.json
user_email = json_data.get('user')
if not isinstance(user_email, str) or not user_email:
return self.fail(1, 'User is required')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
enabled = user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid)
return self.success(data={'totp_enabled': enabled})
@self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Report whether the current Account has TOTP enabled."""
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')
return self.success(
data={
'enabled': await self.ap.totp_service.is_enabled(user_obj.uuid),
'remaining_recovery_codes': await self.ap.totp_service.remaining_recovery_codes(
user_obj.uuid,
),
}
)
@self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Start TOTP enrolment and return the QR payload plus recovery codes.
The secret is not enforced until ``/totp/enroll/verify`` confirms the
authenticator app can produce a valid code.
"""
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')
try:
enrollment, recovery_codes = await self.ap.totp_service.begin_enrollment(user_obj)
except TotpAlreadyEnabledError as e:
return self.http_status(409, e.code, str(e))
return self.success(
data={
'secret': enrollment.secret,
'otpauth_uri': enrollment.otpauth_uri,
'qr_svg': self.ap.totp_service.build_qr_svg(enrollment.otpauth_uri),
'recovery_codes': recovery_codes,
}
)
@self.route('/totp/enroll/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Confirm enrolment with the first code from the authenticator app."""
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
code = json_data.get('code')
if not code:
return self.fail(1, 'Verification code is required')
try:
await self.ap.totp_service.confirm_enrollment(user_obj.uuid, str(code))
except TotpNotEnabledError as e:
return self.http_status(400, e.code, str(e))
except TotpAlreadyEnabledError as e:
return self.http_status(409, e.code, str(e))
except TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
return self.success(data={'enabled': True})
@self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Regenerate one-time recovery codes after proving a valid TOTP code."""
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
code = json_data.get('code')
if not code:
return self.fail(1, 'Verification code is required')
if not await self.ap.totp_service.verify_for_account(user_obj.uuid, str(code)):
return self.http_status(400, TotpInvalidCodeError.code, 'Invalid verification code')
try:
_, recovery_codes = await self.ap.totp_service.regenerate_recovery_codes(
user_obj.uuid,
)
except TotpNotEnabledError as e:
return self.http_status(400, e.code, str(e))
return self.success(data={'recovery_codes': recovery_codes})
@self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Disable TOTP for the current Account after a valid code check."""
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
code = json_data.get('code')
if not code:
return self.fail(1, 'Verification code is required')
try:
await self.ap.totp_service.disable(user_obj.uuid, str(code))
except TotpNotEnabledError as e:
return self.http_status(400, e.code, str(e))
except TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
return self.success(data={'enabled': False})
async def _handle_space_direct_launch(
self,
launch_assertion: str,
-478
View File
@@ -1,478 +0,0 @@
"""Second-factor TOTP (RFC 6238) enrolment, verification and recovery.
This service backs the optional TOTP second factor for LangBot Accounts:
* the shared secret is encrypted at rest with a Fernet key derived from the
instance JWT secret via HKDF, and is never persisted in plaintext;
* recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests;
* the plaintext secret and recovery codes leave the server exactly once, in the
enrolment response.
"""
from __future__ import annotations
import asyncio
import base64
import dataclasses
import datetime
import hashlib
import hmac
import json
import logging
import secrets
import struct
import time
import typing
import uuid
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import totp
from ....entity.persistence import user
if typing.TYPE_CHECKING:
from ....core.app import Application
_logger = logging.getLogger(__name__)
# RFC 6238 parameters. Six digits and a 30 second step are what every common
# authenticator app (Google Authenticator, Authy, 1Password, ...) defaults to.
_TOTP_DIGITS = 6
_TOTP_STEP_SECONDS = 30
# Accept one step of clock skew in either direction, which tolerates small
# device clock drift without materially widening the brute-force window.
_TOTP_WINDOW_STEPS = 1
_RECOVERY_CODE_COUNT = 10
# 10 groups drawn from 32 symbols provide 50 bits of entropy per recovery code.
_RECOVERY_CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
_RECOVERY_CODE_LENGTH = 10
# Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests. The work
# factor is intentionally high: guessing is already infeasible against 50 bits of
# entropy, and the slow KDF keeps a dumped database from being attacked cheaply.
# Hashing runs off the event loop, so this is a latency cost paid only at
# enrolment / regeneration / redemption.
_RECOVERY_CODE_KDF_ITERATIONS = 300_000
class TotpAlreadyEnabledError(ValueError):
"""Raised when enrolling an Account that already has TOTP enabled."""
code = 'totp_already_enabled'
class TotpNotEnabledError(ValueError):
"""Raised when an operation requires an enabled TOTP credential."""
code = 'totp_not_enabled'
class TotpInvalidCodeError(ValueError):
"""Raised when a supplied TOTP or recovery code fails verification."""
code = 'totp_invalid_code'
@dataclasses.dataclass(frozen=True, slots=True)
class TotpEnrollment:
"""Result of starting (or restarting) TOTP enrolment for an Account."""
secret: str
otpauth_uri: str
class TotpService:
"""Second-factor TOTP enrolment, verification and recovery for Accounts.
Nothing usable is persisted in plaintext:
* The shared TOTP secret is encrypted at rest with a Fernet key derived from
the instance JWT secret via HKDF, so a leaked database file alone does not
expose live secrets (the attacker additionally needs ``config.yaml``).
* Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests and are
consumed one at a time.
* The plaintext secret / recovery codes leave the server exactly once, in the
enrolment response, and are never stored or logged server-side.
"""
ap: Application
def __init__(self, ap: Application) -> None:
self.ap = ap
# -- storage helpers -------------------------------------------------
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
def _encryption_key(self) -> bytes:
"""Derive a stable 32-byte Fernet key from the instance JWT secret.
HKDF-SHA256 with a fixed domain-separation salt keeps the key stable
across restarts and distinct from the JWT signing secret. The key
material is NOT stored in the database, so a leaked ``langbot.db`` alone
cannot decrypt the TOTP secrets.
"""
secret = ''
try:
secret = self.ap.instance_config.data['system']['jwt']['secret'] or ''
except (KeyError, TypeError):
secret = ''
if not secret:
# Defence in depth: a missing JWT secret must not silently produce a
# well-known encryption key. This should never happen because
# GenKeysStage seeds it, but failing closed is safer than encrypting
# with a predictable key. The caller maps this to an invalid-code
# failure, so no plaintext is ever persisted.
raise TotpInvalidCodeError('Instance JWT secret unavailable')
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# HKDF enforces the label internally, so include it as `info`.
derived = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b'langbot-totp-v1',
info=b'langbot-totp-secret-encryption',
).derive(secret.encode('utf-8'))
return base64.urlsafe_b64encode(derived)
def _encrypt_secret(self, secret: str) -> str:
from cryptography.fernet import Fernet
return Fernet(self._encryption_key()).encrypt(secret.encode('utf-8')).decode('ascii')
def _decrypt_secret(self, token: str) -> str:
from cryptography.fernet import Fernet, InvalidToken
try:
return Fernet(self._encryption_key()).decrypt(token.encode('ascii')).decode('utf-8')
except (InvalidToken, ValueError) as exc:
raise TotpInvalidCodeError('Stored TOTP secret cannot be decrypted') from exc
# -- RFC 6238 primitives ---------------------------------------------
@staticmethod
def generate_secret() -> str:
"""Return a fresh base32 secret (160 bits, the RFC 4226 recommendation)."""
return base64.b32encode(secrets.token_bytes(20)).decode('ascii').rstrip('=')
@staticmethod
def _hotp(secret: str, counter: int) -> str:
padding = '=' * (-len(secret) % 8)
key = base64.b32decode(secret.upper() + padding)
msg = struct.pack('>Q', counter)
digest = hmac.new(key, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F
binary = struct.unpack('>I', digest[offset : offset + 4])[0] & 0x7FFFFFFF
return str(binary % (10**_TOTP_DIGITS)).zfill(_TOTP_DIGITS)
@classmethod
def generate_code(cls, secret: str, at: float | None = None) -> str:
"""Return the TOTP code for ``secret`` at the given (or current) time."""
counter = int((at if at is not None else time.time()) // _TOTP_STEP_SECONDS)
return cls._hotp(secret, counter)
@classmethod
def verify_code(cls, secret: str, code: str, at: float | None = None) -> bool:
"""Constant-time check of a user-supplied code within the skew window."""
candidate = (code or '').strip().replace(' ', '')
if not candidate.isdigit() or len(candidate) != _TOTP_DIGITS:
return False
now = at if at is not None else time.time()
counter = int(now // _TOTP_STEP_SECONDS)
for offset in range(-_TOTP_WINDOW_STEPS, _TOTP_WINDOW_STEPS + 1):
expected = cls._hotp(secret, counter + offset)
if hmac.compare_digest(expected, candidate):
return True
return False
@staticmethod
def build_otpauth_uri(secret: str, account_name: str, issuer: str = 'LangBot') -> str:
"""Build the otpauth:// URI an authenticator app scans from the QR code."""
from urllib.parse import quote, urlencode
label = quote(f'{issuer}:{account_name}')
params = urlencode(
{
'secret': secret,
'issuer': issuer,
'algorithm': 'SHA1',
'digits': _TOTP_DIGITS,
'period': _TOTP_STEP_SECONDS,
}
)
return f'otpauth://totp/{label}?{params}'
@staticmethod
def build_qr_svg(otpauth_uri: str) -> str:
"""Render the otpauth URI to an inline SVG QR code.
SVG keeps the response text-only so the frontend can drop it straight
into a dialog without byte-encoding a PNG data URL.
"""
import qrcode
import qrcode.image.svg
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
image_factory=qrcode.image.svg.SvgPathImage,
)
qr.add_data(otpauth_uri)
qr.make(fit=True)
image = qr.make_image()
import io
buffer = io.BytesIO()
image.save(buffer)
return buffer.getvalue().decode('utf-8')
# -- recovery codes ---------------------------------------------------
@staticmethod
def _normalise_recovery_code(code: str) -> str:
return (code or '').strip().upper().replace('-', '').replace(' ', '')
@classmethod
def _hash_recovery_code(cls, code: str, *, salt: bytes | None = None) -> str:
"""Return a self-describing PBKDF2-HMAC-SHA256 digest of a recovery code.
The format is ``pbkdf2_sha256$<iterations>$<salt_hex>$<digest_hex>`` so the
work factor is stored alongside the digest and can be raised later
without invalidating existing codes. Salted and slow, so a database dump
does not allow offline brute-forcing of recovery codes.
"""
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
'sha256',
cls._normalise_recovery_code(code).encode('utf-8'),
salt,
_RECOVERY_CODE_KDF_ITERATIONS,
)
return f'pbkdf2_sha256${_RECOVERY_CODE_KDF_ITERATIONS}${salt.hex()}${digest.hex()}'
@staticmethod
def _split_recovery_digest(stored: str) -> tuple[int, bytes, bytes] | None:
parts = (stored or '').split('$')
if len(parts) != 4 or parts[0] != 'pbkdf2_sha256':
return None
try:
iterations = int(parts[1])
salt = bytes.fromhex(parts[2])
digest = bytes.fromhex(parts[3])
except ValueError:
return None
return iterations, salt, digest
@staticmethod
def _random_recovery_code() -> str:
"""Return one random recovery code from the unambiguous alphabet."""
alphabet = _RECOVERY_CODE_ALPHABET
return ''.join(secrets.choice(alphabet) for _ in range(_RECOVERY_CODE_LENGTH))
@classmethod
async def generate_recovery_codes(cls) -> tuple[list[str], list[str]]:
"""Return ``(plaintext_codes, hashed_codes)`` for one enrolment."""
plaintext: list[str] = []
hashed: list[str] = []
for _ in range(_RECOVERY_CODE_COUNT):
code = cls._random_recovery_code()
plaintext.append(code)
# Offload the expensive KDF so 10 codes do not stall the event loop.
hashed.append(await asyncio.to_thread(cls._hash_recovery_code, code))
return plaintext, hashed
# -- persistence ------------------------------------------------------
@staticmethod
def _credential_statement(account_uuid: str) -> typing.Any:
"""Build the SELECT that loads an Account's TOTP credential row."""
entity = totp.TotpCredential
return sqlalchemy.select(entity).where(entity.account_uuid == account_uuid)
async def get_credential(self, account_uuid: str) -> totp.TotpCredential | None:
"""Load the (single) TOTP credential row for an Account, if any."""
statement = self._credential_statement(account_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def is_enabled(self, account_uuid: str) -> bool:
"""Return whether the Account has a confirmed, enabled TOTP credential."""
credential = await self.get_credential(account_uuid)
return bool(credential and credential.enabled)
async def begin_enrollment(self, account: user.User) -> tuple[TotpEnrollment, list[str]]:
"""Create or replace a pending TOTP secret and return recovery codes.
A previous *enabled* credential is left untouched until the new secret
is confirmed, so a failed re-enrolment cannot lock the account out.
"""
secret = self.generate_secret()
uri = self.build_otpauth_uri(secret, account_name=account.user)
plaintext_codes, hashed_codes = await self.generate_recovery_codes()
async with self._session_factory()() as session:
async with session.begin():
credential = await session.scalar(self._credential_statement(account.uuid))
if credential is None:
credential = totp.TotpCredential(
uuid=str(uuid.uuid4()),
account_uuid=account.uuid,
secret_encrypted=self._encrypt_secret(secret),
account_name=account.user,
enabled=False,
recovery_codes=json.dumps(hashed_codes),
)
session.add(credential)
elif not credential.enabled:
credential.secret_encrypted = self._encrypt_secret(secret)
credential.account_name = account.user
credential.recovery_codes = json.dumps(hashed_codes)
else:
raise TotpAlreadyEnabledError('TOTP is already enabled for this account')
await session.flush()
return TotpEnrollment(secret=secret, otpauth_uri=uri), plaintext_codes
async def confirm_enrollment(self, account_uuid: str, code: str) -> None:
"""Verify the first code and flip the credential to enabled."""
credential = await self.get_credential(account_uuid)
if credential is None:
raise TotpNotEnabledError('No pending TOTP enrolment found')
if credential.enabled:
raise TotpAlreadyEnabledError('TOTP is already enabled for this account')
try:
code_matches = self.verify_code(self._decrypt_secret(credential.secret_encrypted), code)
except TotpInvalidCodeError:
code_matches = False
if not code_matches:
raise TotpInvalidCodeError('Invalid verification code')
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is None:
raise TotpNotEnabledError('No pending TOTP enrolment found')
record.enabled = True
record.last_used_at = datetime.datetime.now()
async def verify_for_account(self, account_uuid: str, code: str) -> bool:
"""Validate a live TOTP code for an enabled credential."""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.enabled:
return False
try:
secret = self._decrypt_secret(credential.secret_encrypted)
except TotpInvalidCodeError:
return False
if not self.verify_code(secret, code):
return False
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is not None:
record.last_used_at = datetime.datetime.now()
return True
@classmethod
def _match_recovery_code(cls, code: str, hashed_codes: list[str]) -> int:
"""Return the index of the matching digest, or -1. Constant-time per entry."""
candidate = cls._normalise_recovery_code(code)
for index, stored in enumerate(hashed_codes):
parsed = cls._split_recovery_digest(stored)
if parsed is None:
continue
iterations, salt, expected = parsed
digest = hashlib.pbkdf2_hmac('sha256', candidate.encode('utf-8'), salt, iterations)
if hmac.compare_digest(digest, expected):
return index
return -1
async def redeem_recovery_code(self, account_uuid: str, code: str) -> bool:
"""Consume a one-time recovery code for password reset fallback."""
credential = await self.get_credential(account_uuid)
if credential is None:
return False
hashed_codes: list[str] = []
if credential.recovery_codes:
try:
parsed = json.loads(credential.recovery_codes)
if isinstance(parsed, list):
hashed_codes = [str(item) for item in parsed]
except (ValueError, TypeError):
hashed_codes = []
# Recomputing PBKDF2 for up to 10 salted digests is CPU-bound; keep it
# off the event loop so a recovery attempt cannot stall other requests.
matched_index = await asyncio.to_thread(self._match_recovery_code, code or '', hashed_codes)
if matched_index < 0:
return False
remaining = hashed_codes[:matched_index] + hashed_codes[matched_index + 1 :]
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is not None:
record.recovery_codes = json.dumps(remaining)
record.last_used_at = datetime.datetime.now()
return True
async def regenerate_recovery_codes(self, account_uuid: str) -> tuple[None, list[str]]:
"""Replace the recovery codes for an enabled credential.
The caller is responsible for proving possession of a valid TOTP code
first; this method only swaps the stored digests for a fresh set and
returns the plaintext codes for one-time display.
"""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.enabled:
raise TotpNotEnabledError('TOTP is not enabled for this account')
plaintext_codes, hashed_codes = await self.generate_recovery_codes()
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is None:
raise TotpNotEnabledError('TOTP is not enabled for this account')
record.recovery_codes = json.dumps(hashed_codes)
record.updated_at = datetime.datetime.now()
return None, plaintext_codes
async def disable(self, account_uuid: str, code: str) -> bool:
"""Remove TOTP after the caller proves possession of a valid factor."""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.enabled:
raise TotpNotEnabledError('TOTP is not enabled for this account')
try:
secret = self._decrypt_secret(credential.secret_encrypted)
code_matches = self.verify_code(secret, code)
except TotpInvalidCodeError:
code_matches = False
if not code_matches:
raise TotpInvalidCodeError('Invalid verification code')
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is not None:
await session.delete(record)
return True
async def remaining_recovery_codes(self, account_uuid: str) -> int:
"""Return how many unused recovery codes remain for the Account."""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.recovery_codes:
return 0
try:
parsed = json.loads(credential.recovery_codes)
except (ValueError, TypeError):
return 0
return len(parsed) if isinstance(parsed, list) else 0
-3
View File
@@ -34,7 +34,6 @@ from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import skill as skill_service
from ..api.http.service import totp as totp_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
@@ -162,8 +161,6 @@ class Application:
user_service: user_service.UserService = None
totp_service: totp_service.TotpService = None
space_service: space_service.SpaceService = None
llm_model_service: model_service.LLMModelsService = None
-4
View File
@@ -28,7 +28,6 @@ from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import skill as skill_service
from ...api.http.service import totp as totp_service
from ...skill import manager as skill_mgr
from ...api.http.service import maintenance as maintenance_service
from ...discover import engine as discover_engine
@@ -199,9 +198,6 @@ class BuildAppStage(stage.BootingStage):
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
totp_service_inst = totp_service.TotpService(ap)
ap.totp_service = totp_service_inst
async def resolve_singleton_execution_context() -> ExecutionContext:
if workspace_policy.multi_workspace_enabled:
raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
@@ -1,60 +0,0 @@
"""Persistence entity for per-Account TOTP (RFC 6238) second factors."""
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class TotpCredential(Base):
"""Per-Account TOTP (RFC 6238) second factor and its recovery codes.
A single row is kept per Account. The shared secret is stored encrypted
(``secret_encrypted``, Fernet keyed off the instance JWT secret via HKDF)
rather than in plaintext, and remains unenforced until the owner confirms
possession by submitting a valid code (``enabled``). Recovery codes are
stored only as salted PBKDF2-HMAC-SHA256 digests, so a database leak does
not hand out account recovery. No plaintext secret or recovery code is ever
persisted; both leave the server exactly once, in the enrolment response.
"""
__tablename__ = 'totp_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,
)
# Fernet-encrypted base32 secret; never exposed to the client after enrol.
secret_encrypted = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
# Issuer label shown inside the authenticator app (e.g. the account email).
account_name = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default='0')
# JSON-encoded list of salted PBKDF2 hashes for the one-time recovery codes.
recovery_codes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
)
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_totp_credentials_account', 'account_uuid', unique=True),
)
@@ -1,50 +0,0 @@
"""add totp credentials table
Revision ID: 0025_totp_credentials
Revises: 0024_passkey_credentials
Create Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0025_totp_credentials'
down_revision = '0024_passkey_credentials'
branch_labels = None
depends_on = None
_TABLE_NAME = 'totp_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('secret_encrypted', sa.Text(), nullable=False),
sa.Column('account_name', sa.String(320), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('recovery_codes', sa.Text(), nullable=True),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index('uq_totp_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
op.create_index('uq_totp_credentials_account', _TABLE_NAME, ['account_uuid'], unique=True)
def downgrade() -> None:
op.drop_index('uq_totp_credentials_account', table_name=_TABLE_NAME)
op.drop_index('uq_totp_credentials_uuid', table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="tokenlab-symbol-title tokenlab-symbol-desc">
<title id="tokenlab-symbol-title">TokenLab</title>
<desc id="tokenlab-symbol-desc">Specimen Split symbol, positive master for sizes from 32 to 96 pixels.</desc>
<g fill="#151714">
<path d="M5 13h14.25L35 43H5V13Z"/>
<path d="M20.7 5H43v30H35.6L20.7 5Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 416 B

@@ -21,11 +21,9 @@ import {
Plus,
Trash2,
Pencil,
ShieldCheck,
} from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import TotpEnrollDialog from './TotpEnrollDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
@@ -58,16 +56,11 @@ export default function AccountSettingsPanel({
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [totpEnabled, setTotpEnabled] = useState(false);
const [remainingRecoveryCodes, setRemainingRecoveryCodes] = useState(0);
const [totpLoading, setTotpLoading] = useState(false);
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
loadPasskeys();
loadTotpStatus();
}
}, [active]);
@@ -98,19 +91,6 @@ export default function AccountSettingsPanel({
}
}
async function loadTotpStatus() {
setTotpLoading(true);
try {
const status = await httpClient.getTotpStatus();
setTotpEnabled(status.enabled);
setRemainingRecoveryCodes(status.remaining_recovery_codes);
} catch {
// ignore
} finally {
setTotpLoading(false);
}
}
const handleAddPasskey = async () => {
setRegisteringPasskey(true);
try {
@@ -352,56 +332,6 @@ export default function AccountSettingsPanel({
</div>
)}
</div>
{/* TOTP (2FA) Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.totpSectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.totpSectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setTotpDialogOpen(true)}
disabled={totpLoading || !systemInfo.allow_modify_login_info}
className="cursor-pointer"
>
{totpLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
{totpEnabled
? t('account.disableTotp')
: t('account.enableTotp')}
</Button>
</div>
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<ShieldCheck className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>
{totpEnabled
? t('account.totpEnabled')
: t('account.totpDisabled')}
</ItemTitle>
<ItemDescription>
{totpEnabled
? t('account.totpRecoveryCodesRemaining', {
count: remainingRecoveryCodes,
})
: t('account.totpSectionDesc')}
</ItemDescription>
</ItemContent>
</Item>
</div>
</div>
)}
@@ -410,13 +340,6 @@ export default function AccountSettingsPanel({
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
<TotpEnrollDialog
open={totpDialogOpen}
onOpenChange={setTotpDialogOpen}
enabled={totpEnabled}
onChanged={loadTotpStatus}
/>
</PanelBody>
);
}
+4 -87
View File
@@ -1241,21 +1241,10 @@ export class BackendClient extends BaseHttpClient {
);
}
public authUser(
user: string,
password: string,
secondFactor?: { totpCode?: string; recoveryCode?: string },
): Promise<ApiRespUserToken> {
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
return this.post(
'/api/v1/user/auth',
{
user,
password,
...(secondFactor?.totpCode ? { totp_code: secondFactor.totpCode } : {}),
...(secondFactor?.recoveryCode
? { recovery_code: secondFactor.recoveryCode }
: {}),
},
{ user, password },
{ skipWorkspace: true },
);
}
@@ -1268,25 +1257,15 @@ export class BackendClient extends BaseHttpClient {
public resetPassword(
user: string,
recoveryKey: string,
newPassword: string,
factor:
| { recoveryKey: string }
| { totpCode: string }
| { recoveryCode: string },
): Promise<{ user: string }> {
return this.post(
'/api/v1/user/reset-password',
{
user,
recovery_key: recoveryKey,
new_password: newPassword,
// Exactly one proof-of-ownership factor is accepted by the backend.
...('recoveryKey' in factor
? { recovery_key: factor.recoveryKey }
: {}),
...('totpCode' in factor ? { totp_code: factor.totpCode } : {}),
...('recoveryCode' in factor
? { recovery_code: factor.recoveryCode }
: {}),
},
{ skipWorkspace: true },
);
@@ -1311,7 +1290,6 @@ export class BackendClient extends BaseHttpClient {
user: string;
account_type: 'local' | 'space';
has_password: boolean;
totp_enabled?: boolean;
}> {
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
}
@@ -1328,28 +1306,12 @@ export class BackendClient extends BaseHttpClient {
space_login_enabled?: boolean;
passkey_login_enabled?: boolean;
passkey_supported?: boolean;
totp_supported?: boolean;
}> {
return this.get('/api/v1/user/account-info', undefined, {
skipWorkspace: true,
});
}
/**
* Whether the account identified by the given email has TOTP enabled.
*
* This endpoint is unauthenticated so the password-recovery page can decide
* whether to offer the TOTP / recovery-code verification methods. The
* response only exposes the boolean capability.
*/
public checkTotpForEmail(user: string): Promise<{ totp_enabled: boolean }> {
return this.post(
'/api/v1/user/totp/check',
{ user },
{ skipWorkspace: true },
);
}
// ============ Passkey (WebAuthn) API ============
public getPasskeyAuthOptions(
email?: string,
@@ -1428,51 +1390,6 @@ export class BackendClient extends BaseHttpClient {
});
}
// ============ TOTP (2FA) API ============
public getTotpStatus(): Promise<{
enabled: boolean;
remaining_recovery_codes: number;
}> {
return this.get('/api/v1/user/totp/status', undefined, {
skipWorkspace: true,
});
}
public beginTotpEnrollment(): Promise<{
secret: string;
otpauth_uri: string;
qr_svg: string;
recovery_codes: string[];
}> {
return this.post('/api/v1/user/totp/enroll', {}, { skipWorkspace: true });
}
public verifyTotpEnrollment(code: string): Promise<{ enabled: boolean }> {
return this.post(
'/api/v1/user/totp/enroll/verify',
{ code },
{ skipWorkspace: true },
);
}
public regenerateTotpRecoveryCodes(
code: string,
): Promise<{ recovery_codes: string[] }> {
return this.post(
'/api/v1/user/totp/recovery-codes',
{ code },
{ skipWorkspace: true },
);
}
public disableTotp(code: string): Promise<{ success: boolean }> {
return this.post(
'/api/v1/user/totp/disable',
{ code },
{ skipWorkspace: true },
);
}
// ============ Workspace API ============
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
+15 -124
View File
@@ -36,7 +36,6 @@ import {
RefreshCw,
Layers,
Fingerprint,
ShieldCheck,
} from 'lucide-react';
import { startAuthentication } from '@simplewebauthn/browser';
import langbotIcon from '@/app/assets/langbot-logo.webp';
@@ -72,15 +71,6 @@ export default function Login() {
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const autoSpaceLoginStarted = useRef(false);
// Second-factor state: when /auth replies with totp_required we keep the
// credentials and ask for a TOTP or recovery code instead of a password.
const [totpRequired, setTotpRequired] = useState(false);
const [totpCode, setTotpCode] = useState('');
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [pendingCredentials, setPendingCredentials] = useState<{
username: string;
password: string;
} | null>(null);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
@@ -233,49 +223,11 @@ export default function Login() {
toast.success(t('common.loginSuccess'));
}
})
.catch((error: unknown) => {
const apiError = error as { code?: string };
if (apiError?.code === 'totp_required') {
// Password was accepted; the account additionally requires TOTP.
setPendingCredentials({ username, password });
setTotpCode('');
setTotpRequired(true);
return;
}
.catch(() => {
toast.error(t('common.loginFailed'));
});
}
async function handleTotpSubmit() {
if (!pendingCredentials || !totpCode.trim()) {
return;
}
setTotpSubmitting(true);
try {
const code = totpCode.trim();
// A recovery code is longer than six digits; treat it as such so users
// can sign in even when the authenticator is unavailable.
const isRecoveryCode = code.replace(/\s/g, '').length !== 6;
const res = await httpClient.authUser(
pendingCredentials.username,
pendingCredentials.password,
isRecoveryCode ? { recoveryCode: code } : { totpCode: code },
);
setTotpRequired(false);
setPendingCredentials(null);
if (await finishLogin(res.token, pendingCredentials.username)) {
toast.success(t('common.loginSuccess'));
}
} catch (error: unknown) {
const apiError = error as { code?: string; message?: string };
// Keep the second-factor step open so the user can retry; surface the
// server message when available.
toast.error(apiError?.message || t('common.loginTotpInvalid'));
} finally {
setTotpSubmitting(false);
}
}
const handleSpaceLoginClick = useCallback(async () => {
setSpaceLoading(true);
try {
@@ -384,67 +336,8 @@ export default function Login() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* TOTP second-factor step: shown after the password is accepted. */}
{totpRequired && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-1 text-center">
<ShieldCheck className="h-8 w-8 text-primary" />
<p className="text-sm font-medium">
{t('common.loginTotpTitle')}
</p>
<p className="text-xs text-muted-foreground">
{t('common.loginTotpDesc')}
</p>
</div>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder={t('common.loginTotpPlaceholder')}
className="pl-10 font-mono tracking-widest"
inputMode="text"
autoComplete="one-time-code"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleTotpSubmit();
}
}}
/>
</div>
<Button
type="button"
className="w-full cursor-pointer"
onClick={handleTotpSubmit}
disabled={totpSubmitting || !totpCode.trim()}
>
{totpSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
{totpSubmitting
? t('common.loginTotpVerifying')
: t('common.loginTotpVerify')}
</Button>
<Button
type="button"
variant="ghost"
className="w-full cursor-pointer"
onClick={() => {
setTotpRequired(false);
setPendingCredentials(null);
setTotpCode('');
}}
>
{t('common.backToLogin')}
</Button>
</div>
)}
{/* Space and password login are per-account capabilities. */}
{!totpRequired && showSpaceLogin && (
{showSpaceLogin && (
<div className="space-y-3">
<Button
type="button"
@@ -462,7 +355,7 @@ export default function Login() {
</div>
)}
{!totpRequired && showPasskeyLogin && (
{showPasskeyLogin && (
<div className="space-y-3">
<Button
type="button"
@@ -482,23 +375,21 @@ export default function Login() {
)}
{/* Divider - only show if both login methods are available */}
{!totpRequired &&
(showSpaceLogin || showPasskeyLogin) &&
showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
)}
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
)}
{/* Password login remains available to every account with a password. */}
{!totpRequired && showLocalLogin && (
{showLocalLogin && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
+1 -6
View File
@@ -22,7 +22,7 @@ import {
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, Loader2, Info, Layers, ShieldCheck } from 'lucide-react';
import { Mail, Lock, Loader2, Info, Layers } from 'lucide-react';
import {
Popover,
PopoverContent,
@@ -236,11 +236,6 @@ export default function Register() {
>
{t('register.registerWithPassword')}
</Button>
{/* Recommend enabling TOTP once the account exists */}
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<span>{t('register.totpHint')}</span>
</p>
</form>
</Form>
</>
+33 -225
View File
@@ -19,24 +19,19 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, ArrowLeft, KeyRound, ShieldCheck } from 'lucide-react';
import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ThemeToggle } from '@/components/ui/theme-toggle';
type RecoveryMethod = 'recoveryKey' | 'totp' | 'recoveryCode';
const formSchema = (t: (key: string) => string) =>
z.object({
email: z.string().email(t('common.invalidEmail')),
recoveryKey: z.string().optional(),
totpCode: z.string().optional(),
recoveryCode: z.string().optional(),
recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')),
newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')),
});
@@ -44,129 +39,34 @@ export default function ResetPassword() {
const navigate = useNavigate();
const { t } = useTranslation();
const [isResetting, setIsResetting] = useState(false);
const [method, setMethod] = useState<RecoveryMethod>('recoveryKey');
// Whether TOTP is enabled for the email currently entered. `null` means we have
// not yet resolved it (empty/invalid email), so the TOTP methods stay disabled
// until we can confirm the account actually enrolled one.
const [totpEnabledForEmail, setTotpEnabledForEmail] = useState<
boolean | null
>(null);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
defaultValues: {
email: '',
recoveryKey: '',
totpCode: '',
recoveryCode: '',
newPassword: '',
},
});
// Watch the email so we can resolve, per account, whether TOTP is enabled.
const email = form.watch('email');
// Resolve whether the entered email has TOTP enabled; only then may the user
// pick the TOTP / recovery-code verification methods. While unresolved (empty
// or invalid email) both TOTP methods stay disabled, so an account without
// TOTP can never select them.
useEffect(() => {
if (!email || !z.string().email().safeParse(email).success) {
setTotpEnabledForEmail(null);
setMethod('recoveryKey');
return;
}
let cancelled = false;
// Debounce so we only query once the user pauses typing.
const timer = setTimeout(() => {
httpClient
.checkTotpForEmail(email)
.then((res) => {
if (cancelled) {
return;
}
setTotpEnabledForEmail(res.totp_enabled);
if (!res.totp_enabled) {
setMethod('recoveryKey');
}
})
.catch(() => {
if (!cancelled) {
// Fail closed: if we cannot confirm TOTP, only the recovery key is
// offered rather than letting an unverified TOTP path through.
setTotpEnabledForEmail(null);
setMethod('recoveryKey');
}
});
}, 400);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [email]);
const totpMethodsDisabled = totpEnabledForEmail !== true;
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
if (method === 'recoveryKey') {
if (!values.recoveryKey || !values.recoveryKey.trim()) {
toast.error(t('resetPassword.recoveryKeyRequired'));
return;
}
handleResetPassword(
values.email,
{ recoveryKey: values.recoveryKey.trim() },
values.newPassword,
);
return;
}
if (method === 'totp') {
if (!values.totpCode || !values.totpCode.trim()) {
toast.error(t('resetPassword.totpCodeRequired'));
return;
}
handleResetPassword(
values.email,
{ totpCode: values.totpCode.trim() },
values.newPassword,
);
return;
}
if (!values.recoveryCode || !values.recoveryCode.trim()) {
toast.error(t('resetPassword.recoveryCodeRequired'));
return;
}
handleResetPassword(
values.email,
{ recoveryCode: values.recoveryCode.trim() },
values.newPassword,
);
handleResetPassword(values.email, values.recoveryKey, values.newPassword);
}
function handleResetPassword(
email: string,
factor:
| { recoveryKey: string }
| { totpCode: string }
| { recoveryCode: string },
recoveryKey: string,
newPassword: string,
) {
setIsResetting(true);
httpClient
.resetPassword(email, newPassword, factor)
.resetPassword(email, recoveryKey, newPassword)
.then(() => {
toast.success(t('resetPassword.resetSuccess'));
navigate('/login');
})
.catch((error: unknown) => {
const apiError = error as { code?: string };
if (apiError?.code === 'totp_not_enabled') {
toast.error(t('resetPassword.totpNotEnabled'));
} else if (apiError?.code === 'totp_invalid_code') {
toast.error(t('resetPassword.invalidTotpCode'));
} else {
toast.error(t('resetPassword.resetFailed'));
}
.catch(() => {
toast.error(t('resetPassword.resetFailed'));
})
.finally(() => {
setIsResetting(false);
@@ -218,124 +118,32 @@ export default function ResetPassword() {
)}
/>
{/* Recovery method selector: recovery key, TOTP, or recovery code.
The TOTP-based methods are only selectable once we have
confirmed the entered account actually enrolled TOTP. */}
<div className="space-y-3">
<FormLabel>{t('resetPassword.verifyMethod')}</FormLabel>
<Tabs
value={method}
onValueChange={(v) => setMethod(v as RecoveryMethod)}
>
<TabsList className="w-full">
<TabsTrigger value="recoveryKey" className="flex-1">
{t('resetPassword.recoveryKey')}
</TabsTrigger>
<TabsTrigger
value="totp"
className="flex-1"
disabled={totpMethodsDisabled}
>
{t('resetPassword.totpMethod')}
</TabsTrigger>
<TabsTrigger
value="recoveryCode"
className="flex-1"
disabled={totpMethodsDisabled}
>
{t('resetPassword.recoveryCodeMethod')}
</TabsTrigger>
</TabsList>
</Tabs>
{totpMethodsDisabled && (
<p className="text-xs text-muted-foreground">
{t('resetPassword.totpMethodsUnavailable')}
</p>
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
</div>
{method === 'recoveryKey' && (
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.totpCode')}</FormLabel>
<FormDescription>
{t('resetPassword.totpMethodDescription')}
</FormDescription>
<FormControl>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterTotpCode')}
className="pl-10 font-mono tracking-widest"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'recoveryCode' && (
<FormField
control={form.control}
name="recoveryCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryCode')}</FormLabel>
<FormControl>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryCode')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
<FormField
control={form.control}
-57
View File
@@ -90,13 +90,6 @@ const enUS = {
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
passkeyLoginFailed: 'Failed to sign in with Passkey',
passkeyNotSupported: 'Passkey is not supported on this browser or device',
loginTotpTitle: 'Two-Factor Verification',
loginTotpDesc:
'Enter the 6-digit code from your authenticator app, or a recovery code',
loginTotpPlaceholder: 'Authenticator or recovery code',
loginTotpVerify: 'Verify',
loginTotpVerifying: 'Verifying...',
loginTotpInvalid: 'Invalid code, please try again',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -1286,8 +1279,6 @@ const enUS = {
registerWithPassword: 'Register with email and password',
initSuccess: 'Initialization successful, please login',
initFailed: 'Initialization failed: ',
totpHint:
'Recommended: enable two-factor authentication (TOTP) after signing in to secure your account.',
},
resetPassword: {
title: 'Reset Password 🔐',
@@ -1307,22 +1298,6 @@ const enUS = {
resetFailed:
'Password reset failed, please check your email and recovery key',
backToLogin: 'Back to Login',
totpMethod: 'TOTP Authenticator',
recoveryCodeMethod: 'Recovery Code',
verifyMethod: 'Verification Method',
totpMethodsUnavailable:
'TOTP is not enabled for this account; only the recovery key can be used.',
totpCode: 'Authenticator Code',
enterTotpCode: 'Enter the 6-digit code from your authenticator app',
recoveryCode: 'Recovery Code',
enterRecoveryCode: 'Enter one of your recovery codes',
totpCodeRequired: 'Authenticator code cannot be empty',
recoveryCodeRequired: 'Recovery code cannot be empty',
totpNotEnabled:
'TOTP is not enabled for this account, use the recovery key instead',
invalidTotpCode: 'Invalid verification code, please try again',
totpMethodDescription:
'Verify with a TOTP authenticator app or one of your recovery codes',
},
embedding: {
description: 'Manage Embedding models for text vectorization',
@@ -1382,38 +1357,6 @@ const enUS = {
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
totpSectionTitle: 'Two-Factor Authentication (TOTP)',
totpSectionDesc:
'Scan a QR code to add a TOTP authenticator for extra login security',
totpEnabled: 'Enabled',
totpDisabled: 'Disabled',
enableTotp: 'Enable TOTP',
disableTotp: 'Disable TOTP',
totpEnabledSuccess: 'Two-factor authentication enabled',
totpDisabledSuccess: 'Two-factor authentication disabled',
totpEnrollTitle: 'Add TOTP Authenticator',
totpEnrollDesc:
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm',
totpScanHint: 'Scan this QR code with your authenticator app',
totpManualSecret: 'Or enter this key manually',
totpCodeLabel: 'Authenticator Code',
totpCodePlaceholder: '6-digit code',
totpVerify: 'Verify and Enable',
totpVerifying: 'Verifying...',
totpRecoveryCodesTitle: 'Recovery Codes',
totpRecoveryCodesDesc:
'Store these codes somewhere safe. Each code can be used once if you lose access to your authenticator.',
totpRecoveryCodesRemaining: '{{count}} recovery codes remaining',
totpRegenerateRecoveryCodes: 'Regenerate Recovery Codes',
totpRecoveryCodesRegenerated: 'Recovery codes regenerated',
totpDisableTitle: 'Disable Two-Factor Authentication',
totpDisableDesc:
'Enter a valid authenticator code to disable two-factor authentication',
totpConfirmDisable: 'Disable',
totpInvalidCode: 'Invalid code, please try again',
totpLoadFailed: 'Failed to load two-factor authentication status',
totpCopySecret: 'Copy key',
totpCopied: 'Copied to clipboard',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
-2
View File
@@ -1330,8 +1330,6 @@ const esES = {
newPasswordRequired: 'La nueva contraseña no puede estar vacía',
resetPassword: 'Restablecer contraseña',
resetting: 'Restableciendo...',
totpMethodsUnavailable:
'TOTP no está habilitado para esta cuenta; solo se puede usar la clave de recuperación.',
resetSuccess:
'Contraseña restablecida correctamente, por favor inicia sesión',
resetFailed:
-55
View File
@@ -92,13 +92,6 @@ const jaJP = {
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
loginTotpTitle: '二要素認証',
loginTotpDesc:
'認証アプリの6桁のコード、またはリカバリーコードを入力してください',
loginTotpPlaceholder: '認証コードまたはリカバリーコード',
loginTotpVerify: '確認',
loginTotpVerifying: '確認中...',
loginTotpInvalid: 'コードが無効です。もう一度お試しください',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -1293,8 +1286,6 @@ const jaJP = {
registerWithPassword: 'メールアドレスとパスワードで登録',
initSuccess: '初期化に成功しました。ログインしてください',
initFailed: '初期化に失敗しました:',
totpHint:
'推奨:ログイン後、アカウント設定で二要素認証(TOTP)を有効にしてアカウントを保護してください。',
},
resetPassword: {
title: 'パスワードをリセット 🔐',
@@ -1314,21 +1305,6 @@ const jaJP = {
resetFailed:
'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',
backToLogin: 'ログインに戻る',
totpMethod: 'TOTP 認証アプリ',
recoveryCodeMethod: 'リカバリーコード',
verifyMethod: '確認方法',
totpMethodsUnavailable:
'このアカウントでは TOTP が有効になっていません。リカバリーキーのみ使用できます。',
totpCode: '認証コード',
enterTotpCode: '認証アプリに表示される6桁のコードを入力',
recoveryCode: 'リカバリーコード',
enterRecoveryCode: 'リカバリーコードのいずれかを入力',
totpCodeRequired: '認証コードは必須です',
recoveryCodeRequired: 'リカバリーコードは必須です',
totpNotEnabled:
'このアカウントでは TOTP が有効になっていません。復旧キーを使用してください',
invalidTotpCode: '認証コードが無効です。もう一度お試しください',
totpMethodDescription: 'TOTP 認証アプリまたはリカバリーコードで確認します',
},
embedding: {
description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',
@@ -1388,37 +1364,6 @@ const jaJP = {
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
totpSectionTitle: '二要素認証 (TOTP)',
totpSectionDesc:
'QR コードをスキャンして TOTP 認証アプリを追加し、ログインの安全性を高めます',
totpEnabled: '有効',
totpDisabled: '無効',
enableTotp: 'TOTP を有効化',
disableTotp: 'TOTP を無効化',
totpEnabledSuccess: '二要素認証を有効にしました',
totpDisabledSuccess: '二要素認証を無効にしました',
totpEnrollTitle: 'TOTP 認証アプリを追加',
totpEnrollDesc:
'認証アプリで QR コードをスキャンし、6桁のコードを入力して確認します',
totpScanHint: '認証アプリでこの QR コードをスキャンしてください',
totpManualSecret: 'またはこのキーを手動で入力',
totpCodeLabel: '認証コード',
totpCodePlaceholder: '6桁のコード',
totpVerify: '確認して有効化',
totpVerifying: '確認中...',
totpRecoveryCodesTitle: 'リカバリーコード',
totpRecoveryCodesDesc:
'これらのコードは安全な場所に保管してください。認証アプリが使えない場合、各コードは一度だけ使用できます。',
totpRecoveryCodesRemaining: '残り {{count}} 個のリカバリーコード',
totpRegenerateRecoveryCodes: 'リカバリーコードを再生成',
totpRecoveryCodesRegenerated: 'リカバリーコードを再生成しました',
totpDisableTitle: '二要素認証を無効化',
totpDisableDesc: '有効な認証コードを入力して二要素認証を無効化します',
totpConfirmDisable: '無効化',
totpInvalidCode: 'コードが無効です。もう一度お試しください',
totpLoadFailed: '二要素認証の状態の読み込みに失敗しました',
totpCopySecret: 'キーをコピー',
totpCopied: 'クリップボードにコピーしました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
-2
View File
@@ -1306,8 +1306,6 @@ const ruRU = {
newPasswordRequired: 'Новый пароль не может быть пустым',
resetPassword: 'Сбросить пароль',
resetting: 'Сброс...',
totpMethodsUnavailable:
'TOTP не включён для этой учётной записи; доступен только ключ восстановления.',
resetSuccess: 'Пароль успешно сброшен, пожалуйста, войдите',
resetFailed: 'Ошибка сброса пароля, проверьте email и ключ восстановления',
backToLogin: 'Вернуться к входу',
-2
View File
@@ -1277,8 +1277,6 @@ const thTH = {
newPasswordRequired: 'รหัสผ่านใหม่ต้องไม่ว่างเปล่า',
resetPassword: 'รีเซ็ตรหัสผ่าน',
resetting: 'กำลังรีเซ็ต...',
totpMethodsUnavailable:
'บัญชีนี้ยังไม่ได้เปิดใช้ TOTP ใช้ได้เฉพาะคีย์กู้คืนเท่านั้น',
resetSuccess: 'รีเซ็ตรหัสผ่านสำเร็จ กรุณาเข้าสู่ระบบ',
resetFailed: 'รีเซ็ตรหัสผ่านล้มเหลว กรุณาตรวจสอบอีเมลและคีย์กู้คืน',
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
-2
View File
@@ -1298,8 +1298,6 @@ const viVN = {
newPasswordRequired: 'Mật khẩu mới không được để trống',
resetPassword: 'Đặt lại mật khẩu',
resetting: 'Đang đặt lại...',
totpMethodsUnavailable:
'TOTP chưa được bật cho tài khoản này; chỉ có thể dùng khóa khôi phục.',
resetSuccess: 'Đặt lại mật khẩu thành công, vui lòng đăng nhập',
resetFailed:
'Đặt lại mật khẩu thất bại, vui lòng kiểm tra email và khóa khôi phục',
-50
View File
@@ -88,12 +88,6 @@ const zhHans = {
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
loginTotpTitle: '两步验证',
loginTotpDesc: '请输入验证器应用中的 6 位验证码,或使用恢复码',
loginTotpPlaceholder: '验证码或恢复码',
loginTotpVerify: '验证',
loginTotpVerifying: '验证中...',
loginTotpInvalid: '验证码无效,请重试',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -1225,8 +1219,6 @@ const zhHans = {
registerWithPassword: '通过邮箱密码组合注册',
initSuccess: '初始化成功 请登录',
initFailed: '初始化失败:',
totpHint:
'推荐:登录后在账户设置中开启两步验证(TOTP)以保护您的账户安全。',
},
resetPassword: {
title: '重置密码 🔐',
@@ -1244,19 +1236,6 @@ const zhHans = {
resetSuccess: '密码重置成功,请登录',
resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确',
backToLogin: '返回登录',
totpMethod: 'TOTP 验证器',
recoveryCodeMethod: '恢复码',
verifyMethod: '验证方式',
totpCode: '验证器验证码',
enterTotpCode: '输入验证器应用中的 6 位验证码',
recoveryCode: '恢复码',
enterRecoveryCode: '输入您的其中一个恢复码',
totpCodeRequired: '验证码不能为空',
recoveryCodeRequired: '恢复码不能为空',
totpNotEnabled: '该账户未开启 TOTP,请改用恢复密钥',
invalidTotpCode: '验证码无效,请重试',
totpMethodDescription: '使用 TOTP 验证器应用或恢复码进行验证',
totpMethodsUnavailable: '该账户未开启 TOTP 验证,仅可使用恢复密钥重置密码',
},
embedding: {
description: '管理嵌入模型,用于向量化文本',
@@ -1312,35 +1291,6 @@ const zhHans = {
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
totpSectionTitle: '两步验证 (TOTP)',
totpSectionDesc: '扫描二维码添加 TOTP 验证器,提升登录安全性',
totpEnabled: '已开启',
totpDisabled: '未开启',
enableTotp: '开启 TOTP',
disableTotp: '关闭 TOTP',
totpEnabledSuccess: '两步验证已开启',
totpDisabledSuccess: '两步验证已关闭',
totpEnrollTitle: '添加 TOTP 验证器',
totpEnrollDesc: '使用验证器应用扫描二维码,然后输入 6 位验证码完成确认',
totpScanHint: '使用验证器应用扫描此二维码',
totpManualSecret: '或手动输入此密钥',
totpCodeLabel: '验证码',
totpCodePlaceholder: '6 位验证码',
totpVerify: '验证并开启',
totpVerifying: '验证中...',
totpRecoveryCodesTitle: '恢复码',
totpRecoveryCodesDesc:
'请妥善保存这些恢复码。当您无法使用验证器时,每个恢复码可使用一次。',
totpRecoveryCodesRemaining: '剩余 {{count}} 个恢复码',
totpRegenerateRecoveryCodes: '重新生成恢复码',
totpRecoveryCodesRegenerated: '恢复码已重新生成',
totpDisableTitle: '关闭两步验证',
totpDisableDesc: '输入有效的验证器验证码以关闭两步验证',
totpConfirmDisable: '关闭',
totpInvalidCode: '验证码无效,请重试',
totpLoadFailed: '加载两步验证状态失败',
totpCopySecret: '复制密钥',
totpCopied: '已复制到剪贴板',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
-1
View File
@@ -1234,7 +1234,6 @@ const zhHant = {
newPasswordRequired: '新密碼不能為空',
resetPassword: '重設密碼',
resetting: '重設中...',
totpMethodsUnavailable: '此帳戶未開啟 TOTP 驗證,僅可使用恢復金鑰重設密碼',
resetSuccess: '密碼重設成功,請登入',
resetFailed: '密碼重設失敗,請檢查電子郵件和恢復金鑰是否正確',
backToLogin: '返回登入',