mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 16:26:02 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca710dbd4 | ||
|
|
47ade18596 | ||
|
|
733c9cdf16 | ||
|
|
bbc508d42f |
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -21,6 +21,7 @@
|
||||
*请在方括号间写`x`以打勾 / Please tick the box with `x`*
|
||||
|
||||
- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?
|
||||
- [ ] 我已签署或将在机器人提示后签署 [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。 / I have signed, or will sign when prompted by the bot, the [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md).
|
||||
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
|
||||
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.
|
||||
|
||||
|
||||
41
.github/workflows/cla.yml
vendored
Normal file
41
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: "CLA Assistant"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
actions: write # re-run the failed CLA check after signing
|
||||
contents: read # signatures are stored in the remote langbot-app/cla repo
|
||||
pull-requests: write # post guidance comments, lock PR after merge
|
||||
statuses: write # set the commit status
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Upstream repo was archived in 2026-03; pin to the v2.6.1 commit SHA.
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# repo-scope PAT with write access to langbot-app/cla
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-document: 'https://github.com/langbot-app/LangBot/blob/master/CLA.md'
|
||||
remote-organization-name: 'langbot-app'
|
||||
remote-repository-name: 'cla'
|
||||
path-to-signatures: 'signatures/version1/cla.json'
|
||||
branch: 'main'
|
||||
allowlist: 'dependabot[bot],github-actions[bot],devin-ai-integration[bot],Copilot,renovate[bot],bot*'
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! :heart: Before we can merge this pull request, we need you to sign the [LangBot Contributor License Agreement (CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md). You keep full copyright of your code — the CLA grants us a license to use and distribute your contribution. Signing takes 10 seconds and covers all repositories in this organization, permanently.
|
||||
|
||||
感谢您的贡献!合并前请阅读并签署[贡献者许可协议(CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。您保留代码的全部版权,签署仅需回复下方指定内容,一次签署对本组织全部仓库永久有效。
|
||||
custom-allsigned-prcomment: 'All contributors have signed the CLA. :white_check_mark: 所有贡献者均已签署 CLA。'
|
||||
lock-pullrequest-aftermerge: true
|
||||
# SECURITY: this workflow runs on pull_request_target (it holds secrets and has
|
||||
# write access to the base repository). NEVER add an actions/checkout step that
|
||||
# checks out the PR's code here.
|
||||
107
CLA.md
Normal file
107
CLA.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# LangBot Individual Contributor License Agreement (v1.0)
|
||||
|
||||
Thank you for your interest in contributing to LangBot (the "Project"), stewarded by Beijing Langbo Intelligent Technology Co., Ltd. (北京浪波智能科技有限公司) ("We" or "Us").
|
||||
|
||||
This Individual Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. By signing this Agreement (see Section 9), You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the licenses granted herein to Us and recipients of software distributed by Us, You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Us.
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Us for inclusion in, or documentation of, any of the products or repositories owned or managed by Us (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
## 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. For clarity, this includes the right for Us to distribute Your Contributions, alone or as part of the Work, under the terms of any license, including without limitation open source licenses and commercial or proprietary licenses.
|
||||
|
||||
## 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which You have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
## 4. Authority; Employer
|
||||
|
||||
You represent that You are legally entitled to grant the above licenses. If Your employer(s) has rights to intellectual property that You create that includes Your Contributions, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to Us, or that Your employer has executed a separate Corporate Contributor License Agreement with Us.
|
||||
|
||||
## 5. Original Creation; Disclosure
|
||||
|
||||
You represent that each of Your Contributions is Your original creation (see Section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which You are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
## 6. No Obligation of Support; Disclaimer
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 7. Third-Party Works
|
||||
|
||||
Should You wish to submit work that is not Your original creation, You may submit it to Us separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which You are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
## 8. Notification
|
||||
|
||||
You agree to notify Us of any facts or circumstances of which You become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
## 9. Electronic Signature
|
||||
|
||||
This Agreement is accepted and signed electronically: posting a comment containing the exact phrase designated by Us (currently "I have read the CLA Document and I hereby sign the CLA") from Your GitHub account on a pull request in the Project's repositories constitutes Your binding electronic signature to this Agreement. You represent that the GitHub account used to sign belongs to You and that You are of legal age to form a binding contract. Your signature covers Your present and future Contributions to all repositories owned or managed by Us, until and unless You notify Us in writing that You withdraw from this Agreement for future Contributions (licenses already granted are irrevocable).
|
||||
|
||||
## 10. Our Commitment
|
||||
|
||||
We commit that the Project's main repository will continue to make an open source version of the Work publicly available.
|
||||
|
||||
## 11. Miscellaneous
|
||||
|
||||
This Agreement is the entire agreement between You and Us regarding Your Contributions and supersedes any prior agreements on this subject. If any provision is held unenforceable, the remaining provisions remain in effect. This Agreement is executed in English; the Chinese translation below is provided for reference only, and the English version shall prevail in case of any discrepancy.
|
||||
|
||||
---
|
||||
|
||||
# LangBot 个人贡献者许可协议(v1.0)中文参考译文
|
||||
|
||||
> 本译文仅供参考,如与英文版有任何歧义,以英文版为准。
|
||||
|
||||
感谢您有意为 LangBot(下称"本项目")作出贡献。本项目由北京浪波智能科技有限公司(下称"我方")运营管理。
|
||||
|
||||
本《个人贡献者许可协议》(下称"本协议")旨在记录贡献者授予我方的各项权利。您一经签署本协议(见第 9 条),即接受并同意以下条款与条件,适用于您向本项目提交的现在及未来的全部贡献。除本协议授予我方及我方分发软件之接收者的许可外,您保留对您的贡献的全部权利、所有权和利益。
|
||||
|
||||
## 1. 定义
|
||||
|
||||
"您"指与我方订立本协议的版权所有人,或经版权所有人授权的法律实体。
|
||||
|
||||
"贡献"指您有意提交给我方、用于纳入我方拥有或管理的任何产品或代码仓库(下称"作品")或其文档的任何原创作品,包括对既有作品的修改或增补。就本定义而言,"提交"指以任何电子、口头或书面形式向我方或我方代表发送的通信,包括但不限于在由我方或代表我方管理的电子邮件列表、源代码管理系统和问题跟踪系统中,为讨论和改进作品而进行的通信;但您以显著方式标注或以书面形式声明为"非贡献"(Not a Contribution)的通信除外。
|
||||
|
||||
## 2. 版权许可的授予
|
||||
|
||||
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的版权许可,以复制您的贡献、基于其创作衍生作品、公开展示、公开表演、再许可以及分发您的贡献及上述衍生作品。为明确起见,上述许可包括我方有权以任何许可条款(包括但不限于开源许可证以及商业或专有许可证)单独或作为作品的一部分分发您的贡献。
|
||||
|
||||
## 3. 专利许可的授予
|
||||
|
||||
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的(本条所述情形除外)专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式转让作品;该许可仅适用于您可许可的、且因您的贡献本身或您的贡献与其所提交之作品的结合而必然受到侵犯的专利权利要求。如任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的交叉请求或反诉),主张您的贡献或您所贡献的作品构成直接或帮助性专利侵权,则依据本协议就该贡献或作品授予该实体的任何专利许可,自该诉讼提起之日起终止。
|
||||
|
||||
## 4. 权利能力与雇主
|
||||
|
||||
您声明您在法律上有权授予上述许可。如您的雇主对您创作的、包含您的贡献在内的知识产权享有权利,您声明:您已获得该雇主代表其作出贡献的许可,或该雇主已就您向我方的贡献放弃上述权利,或该雇主已与我方另行签署《企业贡献者许可协议》。
|
||||
|
||||
## 5. 原创性声明与披露义务
|
||||
|
||||
您声明您的每项贡献均为您的原创作品(代表第三方提交的情形见第 7 条)。您声明您提交的贡献中已完整披露您本人知悉的、与您的贡献任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的全部细节。
|
||||
|
||||
## 6. 无支持义务;免责声明
|
||||
|
||||
您无义务为您的贡献提供支持,除非您自愿提供。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或另有书面约定,您的贡献按"现状"(AS IS)提供,不附带任何明示或默示的保证或条件,包括但不限于关于权属、不侵权、适销性或特定用途适用性的任何保证或条件。
|
||||
|
||||
## 7. 第三方作品
|
||||
|
||||
如您希望提交非您原创的作品,您可以将其与任何贡献分开单独提交给我方,并完整说明其来源以及您本人知悉的任何许可或其他限制(包括但不限于相关专利、商标和许可协议)的全部细节,同时以显著方式将该作品标注为"代表第三方提交:[此处注明第三方名称]"。
|
||||
|
||||
## 8. 通知义务
|
||||
|
||||
如您知悉任何事实或情况将导致上述声明在任何方面不准确,您同意通知我方。
|
||||
|
||||
## 9. 电子签署
|
||||
|
||||
本协议以电子方式接受并签署:您通过您的 GitHub 账号,在本项目代码仓库的拉取请求(pull request)中发表包含我方指定语句(现为 "I have read the CLA Document and I hereby sign the CLA")的评论,即构成您对本协议具有约束力的电子签名。您声明用于签署的 GitHub 账号归您本人所有,且您已达到订立有约束力合同的法定年龄。您的签署覆盖您对我方拥有或管理的全部代码仓库的现在及未来的贡献,直至您以书面形式通知我方就未来贡献退出本协议为止(已授予的许可不可撤销)。
|
||||
|
||||
## 10. 我方承诺
|
||||
|
||||
我方承诺本项目主仓库将持续公开提供作品的开源版本。
|
||||
|
||||
## 11. 其他
|
||||
|
||||
本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。
|
||||
@@ -14,6 +14,12 @@
|
||||
- 在 PR 和 Commit Message 中请使用全英文
|
||||
- 对于中文用户,issue 中可以使用中文
|
||||
|
||||
### 贡献者许可协议(CLA)
|
||||
|
||||
为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议(CLA)](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。
|
||||
|
||||
签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。
|
||||
|
||||
<hr/>
|
||||
|
||||
## Guidelines
|
||||
@@ -29,3 +35,9 @@
|
||||
|
||||
- Use English in PRs and Commit Messages
|
||||
- For English users, you can use English in issues
|
||||
|
||||
### Contributor License Agreement (CLA)
|
||||
|
||||
To protect the project and every contributor, we require all code contributors to sign our [Contributor License Agreement](./CLA.md). This is standard practice in major open source projects such as Apache, Google, and Grafana: you keep full copyright of your code — the CLA only grants us a license to use and distribute your contribution.
|
||||
|
||||
Signing takes 10 seconds: when you open your first PR, a bot will guide you to reply with a single comment. One signature covers all repositories in this organization, permanently. Past contributions are not affected.
|
||||
|
||||
@@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ``system.outbound_ips`` may be a comma-separated string instead of
|
||||
# a list when injected via the SYSTEM__OUTBOUND_IPS env var into a
|
||||
# pre-existing data/config.yaml that lacks the key (env overrides
|
||||
# only coerce to list when the key already holds one).
|
||||
outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', [])
|
||||
if isinstance(outbound_ips, str):
|
||||
outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()]
|
||||
elif isinstance(outbound_ips, list):
|
||||
outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()]
|
||||
else:
|
||||
outbound_ips = []
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
@@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service', False
|
||||
),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'outbound_ips': outbound_ips,
|
||||
'wizard_status': wizard_status,
|
||||
'wizard_progress': wizard_progress,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -20,6 +21,66 @@ log_colors_config = {
|
||||
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
|
||||
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
|
||||
|
||||
LOG_DIR = 'data/logs'
|
||||
|
||||
|
||||
class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``.
|
||||
|
||||
It combines two rotation triggers:
|
||||
|
||||
* **Size** — within a single day the file is rotated once it exceeds
|
||||
``maxBytes``, producing numbered backups (``langbot-DATE.log.1`` etc.),
|
||||
exactly like :class:`~logging.handlers.RotatingFileHandler`.
|
||||
* **Date** — when the local date changes, logging switches to a fresh
|
||||
``langbot-<new date>.log`` file. This happens even within a single
|
||||
long-running process, so a bot started on day N keeps writing to that
|
||||
day's file and rolls over to day N+1's file at midnight, instead of
|
||||
appending every subsequent day's logs to the start-day file.
|
||||
|
||||
The on-disk naming stays compatible with the log-retention cleanup in
|
||||
``api/http/service/maintenance.py`` (``LOG_FILE_PATTERN``).
|
||||
"""
|
||||
|
||||
def __init__(self, log_dir: str, max_bytes: int, backup_count: int, encoding: str = 'utf-8'):
|
||||
self.log_dir = log_dir
|
||||
self._current_date = self._today()
|
||||
super().__init__(
|
||||
self._build_path(self._current_date),
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding=encoding,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _today() -> str:
|
||||
return time.strftime('%Y-%m-%d', time.localtime())
|
||||
|
||||
def _build_path(self, date_str: str) -> str:
|
||||
return os.path.join(self.log_dir, 'langbot-%s.log' % date_str)
|
||||
|
||||
def shouldRollover(self, record):
|
||||
# Roll over when the day changes, regardless of file size.
|
||||
if self._today() != self._current_date:
|
||||
return True
|
||||
return super().shouldRollover(record)
|
||||
|
||||
def doRollover(self):
|
||||
today = self._today()
|
||||
if today != self._current_date:
|
||||
# Date changed: point the handler at the new day's file.
|
||||
# This is a date switch, not a size-based numbered rotation.
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
self._current_date = today
|
||||
self.baseFilename = os.path.abspath(self._build_path(today))
|
||||
if not self.delay:
|
||||
self.stream = self._open()
|
||||
else:
|
||||
# Same day, file exceeded maxBytes: numbered rotation.
|
||||
super().doRollover()
|
||||
|
||||
|
||||
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
|
||||
# Remove all existing loggers
|
||||
@@ -31,8 +92,6 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
if constants.debug_mode:
|
||||
level = logging.DEBUG
|
||||
|
||||
log_file_name = 'data/logs/langbot-%s.log' % time.strftime('%Y-%m-%d', time.localtime())
|
||||
|
||||
qcg_logger = logging.getLogger('langbot')
|
||||
|
||||
qcg_logger.setLevel(level)
|
||||
@@ -48,12 +107,13 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
# stream_handler.setFormatter(color_formatter)
|
||||
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
||||
|
||||
# Use RotatingFileHandler to prevent unbounded log file growth
|
||||
rotating_file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_name,
|
||||
# Rotate by size within a day and switch files when the date changes,
|
||||
# so long-running processes still produce a log file for the current day.
|
||||
rotating_file_handler = DailyGroupedRotatingFileHandler(
|
||||
LOG_DIR,
|
||||
max_bytes=LOG_FILE_MAX_BYTES,
|
||||
backup_count=LOG_FILE_BACKUP_COUNT,
|
||||
encoding='utf-8',
|
||||
maxBytes=LOG_FILE_MAX_BYTES,
|
||||
backupCount=LOG_FILE_BACKUP_COUNT,
|
||||
)
|
||||
|
||||
log_handlers: list[logging.Handler] = [
|
||||
|
||||
@@ -31,6 +31,18 @@ spec:
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: IP Whitelist
|
||||
zh_Hans: IP 白名单
|
||||
zh_Hant: IP 白名單
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official Account platform
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
|
||||
@@ -19,6 +19,18 @@ spec:
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: IP Whitelist
|
||||
zh_Hans: IP 白名单
|
||||
zh_Hant: IP 白名單
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
|
||||
@@ -32,6 +32,18 @@ spec:
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: Trusted IPs
|
||||
zh_Hans: 企业可信 IP
|
||||
zh_Hant: 企業可信 IP
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of your app in the WeCom admin console
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台应用详情页的「企业可信 IP」中
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台應用詳情頁的「企業可信 IP」中
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -75,6 +75,18 @@ spec:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: Trusted IPs
|
||||
zh_Hans: 企业可信 IP
|
||||
zh_Hant: 企業可信 IP
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of the bot configuration in the WeCom admin console
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台智能机器人配置的「企业可信 IP」中
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台智慧機器人設定的「企業可信 IP」中
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: Secret
|
||||
label:
|
||||
en_US: Secret
|
||||
|
||||
@@ -31,6 +31,18 @@ spec:
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: Trusted IPs
|
||||
zh_Hans: 企业可信 IP
|
||||
zh_Hant: 企業可信 IP
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of WeChat Customer Service in the WeCom admin console
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台微信客服的「企业可信 IP」中
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台微信客服的「企業可信 IP」中
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -21,6 +21,13 @@ system:
|
||||
recovery_key: ''
|
||||
allow_modify_login_info: true
|
||||
disabled_adapters: []
|
||||
# Public outbound IP addresses of this LangBot deployment. Some platforms
|
||||
# (e.g. WeCom, WeChat Official Account, QQ Official API) require the
|
||||
# caller's IPs to be added to their trusted-IP / IP-whitelist settings.
|
||||
# When set, the web UI shows these IPs on the bot config form of such
|
||||
# adapters. Also settable via the SYSTEM__OUTBOUND_IPS env var
|
||||
# (comma-separated). Empty list = hidden in the web UI.
|
||||
outbound_ips: []
|
||||
limitation:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
|
||||
120
tests/unit_tests/core/test_bootutils_log.py
Normal file
120
tests/unit_tests/core/test_bootutils_log.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for the daily-grouped rotating log file handler.
|
||||
|
||||
Regression coverage for the bug where a long-running process names its log
|
||||
file after the *start* day and keeps appending to it across midnight, so no
|
||||
file ever appears for the current day. See
|
||||
``langbot.pkg.core.bootutils.log.DailyGroupedRotatingFileHandler``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import langbot.pkg.core.bootutils.log as logmod
|
||||
from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler
|
||||
|
||||
# Mirror of the cleanup pattern in api/http/service/maintenance.py.
|
||||
MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||
|
||||
|
||||
def _listing(directory):
|
||||
return sorted(os.listdir(directory))
|
||||
|
||||
|
||||
def _make_logger(handler, name):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(handler)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
|
||||
class TestDailyGroupedRotatingFileHandler:
|
||||
def _patch_date(self, monkeypatch, box):
|
||||
"""Make the handler read its current date from ``box['date']``."""
|
||||
|
||||
def fake_strftime(fmt, t=None):
|
||||
if fmt == '%Y-%m-%d':
|
||||
return box['date']
|
||||
return '00:00:00'
|
||||
|
||||
monkeypatch.setattr(logmod.time, 'strftime', fake_strftime)
|
||||
|
||||
def test_initial_file_named_for_current_day(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_initial')
|
||||
logger.info('hello')
|
||||
handler.close()
|
||||
|
||||
assert _listing(tmp_path) == ['langbot-2026-06-08.log']
|
||||
|
||||
def test_same_day_size_rotation_creates_numbered_backups(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_size')
|
||||
for i in range(40):
|
||||
logger.info('padding line to exceed maxBytes %d', i)
|
||||
handler.close()
|
||||
|
||||
files = _listing(tmp_path)
|
||||
assert 'langbot-2026-06-08.log' in files
|
||||
assert any(f.startswith('langbot-2026-06-08.log.') for f in files)
|
||||
|
||||
def test_rolls_to_new_file_when_day_changes(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_midnight')
|
||||
logger.info('day1 line')
|
||||
|
||||
# Simulate crossing midnight within the same running process.
|
||||
box['date'] = '2026-06-09'
|
||||
logger.info('day2 line after midnight')
|
||||
handler.close()
|
||||
|
||||
files = _listing(tmp_path)
|
||||
assert 'langbot-2026-06-08.log' in files
|
||||
assert 'langbot-2026-06-09.log' in files
|
||||
|
||||
day2 = (tmp_path / 'langbot-2026-06-09.log').read_text(encoding='utf-8')
|
||||
assert 'day2 line after midnight' in day2
|
||||
assert 'day1 line' not in day2
|
||||
|
||||
def test_rollover_repeats_across_multiple_days(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_multiday')
|
||||
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
|
||||
box['date'] = day
|
||||
logger.info('line for %s', day)
|
||||
handler.close()
|
||||
|
||||
files = _listing(tmp_path)
|
||||
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
|
||||
assert f'langbot-{day}.log' in files
|
||||
|
||||
def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch):
|
||||
box = {'date': '2026-06-08'}
|
||||
self._patch_date(monkeypatch, box)
|
||||
|
||||
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
|
||||
logger = _make_logger(handler, 'lb_logtest_pattern')
|
||||
for i in range(40):
|
||||
logger.info('padding line %d', i)
|
||||
box['date'] = '2026-06-09'
|
||||
logger.info('next day line')
|
||||
handler.close()
|
||||
|
||||
for name in _listing(tmp_path):
|
||||
assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name
|
||||
@@ -13,6 +13,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import { UUID } from 'uuidjs';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Bot } from '@/app/infra/entities/api';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
@@ -621,6 +622,7 @@ export default function BotForm({
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import {
|
||||
IDynamicFormItemSchema,
|
||||
SYSTEM_FIELD_PREFIX,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -44,8 +47,8 @@ function resolveShowIfValue(
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith('__system.')) {
|
||||
const key = field.slice('__system.'.length);
|
||||
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
|
||||
return systemContext?.[key];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
@@ -198,6 +201,66 @@ function WebhookUrlField({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for `__system.*` fields (e.g. the deployment's
|
||||
* outbound IPs that the operator must add to a platform's trusted-IP list).
|
||||
* Renders one read-only row per value, each with a copy button. Rendered
|
||||
* outside of react-hook-form binding since the values come from
|
||||
* systemContext, not user input.
|
||||
*/
|
||||
function SystemInfoField({
|
||||
label,
|
||||
description,
|
||||
values,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
values: string[];
|
||||
}) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleCopy = (text: string, index: number) => {
|
||||
copyToClipboard(text).catch(() => {});
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem className="min-w-0">
|
||||
<FormLabel className="break-words">{label}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{values.map((value, index) => (
|
||||
<div key={index} className="flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
readOnly
|
||||
className="min-w-0 flex-1 bg-muted"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(value, index)}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Hover-only Radix tooltips never open on touch devices (no pointer hover),
|
||||
// so the ``disabled_tooltip`` explaining why a field is locked was invisible on
|
||||
// mobile. This wrapper makes the info icon also toggle the tooltip on tap while
|
||||
@@ -290,15 +353,17 @@ export default function DynamicFormComponent({
|
||||
return value;
|
||||
};
|
||||
|
||||
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
|
||||
// participate in form state, validation, or value emission.
|
||||
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||
// and `__system.*`-named fields) that should not participate in form state,
|
||||
// validation, or value emission.
|
||||
const editableItems = useMemo(
|
||||
() =>
|
||||
itemConfigList.filter(
|
||||
(item) =>
|
||||
item.type !== 'webhook-url' &&
|
||||
item.type !== 'embed-code' &&
|
||||
item.type !== 'qr-code-login',
|
||||
item.type !== 'qr-code-login' &&
|
||||
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||
),
|
||||
[itemConfigList],
|
||||
);
|
||||
@@ -583,6 +648,31 @@ export default function DynamicFormComponent({
|
||||
<DisabledTooltipIcon text={disabledTooltip} />
|
||||
) : null;
|
||||
|
||||
// `__system.*` fields are display-only; their value is resolved
|
||||
// from systemContext (same namespace as show_if), not user input.
|
||||
// Hidden entirely when the deployment doesn't provide the value.
|
||||
if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
const rawValue =
|
||||
systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)];
|
||||
const values = (Array.isArray(rawValue) ? rawValue : [rawValue])
|
||||
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||
.map(String);
|
||||
if (values.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SystemInfoField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (config.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemOption,
|
||||
IShowIfCondition,
|
||||
SYSTEM_FIELD_PREFIX,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
@@ -50,6 +51,11 @@ export function getDefaultValues(
|
||||
): Record<string, any> {
|
||||
return itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
// `__system.*` fields are display-only (resolved from systemContext);
|
||||
// their placeholder defaults must not leak into the config values.
|
||||
if (item.name.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
return acc;
|
||||
}
|
||||
acc[item.name] = item.default;
|
||||
return acc;
|
||||
},
|
||||
|
||||
@@ -348,6 +348,10 @@ export interface ApiRespSystemInfo {
|
||||
allow_modify_login_info: boolean;
|
||||
disable_models_service: boolean;
|
||||
limitation: SystemLimitation;
|
||||
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
|
||||
* config.yaml). Shown on adapter config forms whose platform requires
|
||||
* trusted-IP / IP-whitelist settings. Empty = not configured. */
|
||||
outbound_ips: string[];
|
||||
wizard_status: string; // 'none' | 'skipped' | 'completed'
|
||||
wizard_progress: WizardProgress | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
/** Namespace prefix shared by ``show_if.field`` references and display-only
|
||||
* form item names whose value is resolved from the caller-supplied
|
||||
* ``DynamicFormComponent.systemContext``. */
|
||||
export const SYSTEM_FIELD_PREFIX = '__system.';
|
||||
|
||||
export interface IShowIfCondition {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'in';
|
||||
@@ -11,6 +16,12 @@ export interface IDynamicFormItemSchema {
|
||||
id: string;
|
||||
default: string | number | boolean | Array<unknown>;
|
||||
label: I18nObject;
|
||||
/** Form value key. Names prefixed with ``__system.`` denote display-only
|
||||
* fields whose value is resolved from
|
||||
* ``DynamicFormComponent.systemContext`` (e.g. ``__system.outbound_ips``
|
||||
* → ``systemContext.outbound_ips``) — same namespace as ``show_if``.
|
||||
* Such fields are rendered read-only with copy buttons, excluded from
|
||||
* form state/validation/emission, and hidden when the value is empty. */
|
||||
name: string;
|
||||
required: boolean;
|
||||
type: DynamicFormItemType;
|
||||
|
||||
@@ -16,6 +16,7 @@ export const systemInfo: ApiRespSystemInfo = {
|
||||
max_pipelines: -1,
|
||||
max_extensions: -1,
|
||||
},
|
||||
outbound_ips: [],
|
||||
wizard_status: 'none',
|
||||
wizard_progress: null,
|
||||
};
|
||||
|
||||
@@ -939,6 +939,7 @@ function StepBotConfig({
|
||||
is_wizard: true,
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user