Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter
53296fcc73 fix(docs): restore cli command pages 2026-06-16 23:47:30 +08:00
11 changed files with 102 additions and 292 deletions

View File

@@ -9,8 +9,6 @@ from filelock import FileLock, Timeout
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
async def run_astrbot(astrbot_root: Path) -> None:
"""Run AstrBot"""
@@ -30,13 +28,8 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option(
"--reset-password",
is_flag=True,
help="Reset dashboard initial password on startup",
)
@click.command()
def run(reload: bool, port: str | None, reset_password: bool) -> None:
def run(reload: bool, port: str) -> None:
"""Run AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
@@ -57,9 +50,6 @@ def run(reload: bool, port: str | None, reset_password: bool) -> None:
click.echo("Plugin auto-reload enabled")
os.environ["ASTRBOT_RELOAD"] = "1"
if reset_password:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():

View File

@@ -16,7 +16,6 @@ from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
logger = logging.getLogger("astrbot")
@@ -78,11 +77,7 @@ class AstrBotConfig(dict):
)
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
reset_dashboard_password = self._consume_reset_dashboard_password_flag()
if reset_dashboard_password and "dashboard" in conf:
self._reset_generated_dashboard_password(conf)
has_new = True
elif (
if (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and not conf["dashboard"].get("pbkdf2_password")
@@ -123,11 +118,6 @@ class AstrBotConfig(dict):
True,
)
@staticmethod
def _consume_reset_dashboard_password_flag() -> bool:
raw_value = os.environ.pop(DASHBOARD_RESET_PASSWORD_ENV, "")
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
@staticmethod
def _resolve_initial_dashboard_password() -> str:
env_password = os.environ.get(DASHBOARD_INITIAL_PASSWORD_ENV)

View File

@@ -186,6 +186,7 @@ class ResultDecorateStage(Stage):
# 流式输出不执行下面的逻辑
if is_stream:
logger.info("流式输出已启用,跳过结果装饰阶段")
return
# 需要再获取一次。插件可能直接对 chain 进行了替换。

View File

@@ -90,7 +90,7 @@ class PipelineScheduler:
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
logger.debug("pipeline execution completed.")
logger.debug("pipeline 执行完毕。")
finally:
event.cleanup_temporary_local_files()
active_event_registry.unregister(event)

View File

@@ -97,6 +97,7 @@ class ProviderGoogleGenAI(Provider):
if proxy:
async_client_kwargs["proxy"] = proxy
async_client_kwargs["trust_env"] = False
logger.info("[Gemini] 使用代理")
else:
async_client_kwargs["trust_env"] = True
@@ -136,18 +137,15 @@ class ProviderGoogleGenAI(Provider):
keys.remove(self.chosen_api_key)
if len(keys) > 0:
self.set_key(random.choice(keys))
logger.warning(
"Retrying with a different API key due to detected key issue: %s. Current key: %s...",
e.message,
self.chosen_api_key[:12],
logger.info(
f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试... 当前 Key: {self.chosen_api_key[:12]}...",
)
await asyncio.sleep(1)
return True
logger.error(
"No valid API keys remaining. Current key: %s...",
self.chosen_api_key[:12],
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
)
raise Exception("Gemini API rate limit reached or API key issue detected.")
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
# 连接错误处理
if is_connection_error(e):
@@ -174,9 +172,7 @@ class ProviderGoogleGenAI(Provider):
self.provider_settings.get("streaming_response", False)
and "IMAGE" in modalities
):
logger.warning(
"Streaming responses do not support IMAGE modality, falling back to TEXT modality."
)
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
modalities = ["TEXT"]
tool_list: list[types.Tool] | None = []
@@ -185,28 +181,60 @@ class ProviderGoogleGenAI(Provider):
native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False)
if "gemini-2.0-lite" in model_name:
if "gemini-2.5" in model_name:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
if native_search:
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
if url_context:
logger.warning(
"代码执行工具与URL上下文工具互斥已忽略URL上下文工具",
)
else:
if native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
if url_context:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
elif "gemini-2.0-lite" in model_name:
if native_coderunner or native_search or url_context:
logger.warning(
"gemini-2.0-lite does not support native code execution, search, or URL context tools. These settings will be ignored.",
"gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文将忽略这些设置",
)
tool_list = None
else:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
if native_search:
if native_search:
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
elif native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
if url_context:
tool_list.append(types.Tool(url_context=types.UrlContext()))
if tools:
func_desc = tools.get_func_desc_google_genai_style()
tool_list.append(
types.Tool(function_declarations=func_desc["function_declarations"]),
)
if url_context and not native_coderunner:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
if not tool_list:
tool_list = None
if tools and tool_list:
logger.warning("已启用原生工具,函数工具将被忽略")
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
tool_list = [
types.Tool(function_declarations=func_desc["function_declarations"]),
]
tool_config = None
has_func_decl = tool_list and any(t.function_declarations for t in tool_list)
if has_func_decl:
@@ -294,7 +322,7 @@ class ProviderGoogleGenAI(Provider):
def create_text_part(text: str) -> types.Part:
content_a = text if text else " "
if not text:
logger.warning("Text content is empty, added a space as placeholder.")
logger.warning("文本内容为空,已添加空格占位")
return types.Part.from_text(text=content_a)
def process_image_url(image_url_dict: dict) -> types.Part:
@@ -321,6 +349,12 @@ class ProviderGoogleGenAI(Provider):
contents.append(content_cls(parts=part))
gemini_contents: list[types.Content] = []
native_tool_enabled = any(
[
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
],
)
for message in payloads["messages"]:
role, content = message["role"], message.get("content")
@@ -343,10 +377,11 @@ class ProviderGoogleGenAI(Provider):
append_or_extend(gemini_contents, parts, types.UserContent)
elif role == "assistant":
parts = []
if isinstance(content, str):
parts.append(types.Part.from_text(text=content))
parts = [types.Part.from_text(text=content)]
append_or_extend(gemini_contents, parts, types.ModelContent)
elif isinstance(content, list):
parts = []
thinking_signature = None
text = ""
for part in content:
@@ -365,31 +400,16 @@ class ProviderGoogleGenAI(Provider):
exc_info=True,
)
thinking_signature = None
if (
not text
and thinking_signature
and "tool_calls" in message
and any(
isinstance(tool, dict)
and isinstance(tool.get("extra_content"), dict)
and isinstance(tool["extra_content"].get("google"), dict)
and tool["extra_content"]["google"].get("thought_signature")
for tool in message["tool_calls"]
)
):
# If the main content is empty but tool calls have thought signatures,
# skip adding an empty text part to deduplicate the thinking signature in the main content and tool calls.
pass
else:
parts.append(
types.Part(
text=text,
thought_signature=thinking_signature,
)
parts.append(
types.Part(
text=text,
thought_signature=thinking_signature,
)
)
append_or_extend(gemini_contents, parts, types.ModelContent)
if "tool_calls" in message:
elif not native_tool_enabled and "tool_calls" in message:
parts = []
for tool in message["tool_calls"]:
part = types.Part.from_function_call(
name=tool["function"]["name"],
@@ -407,13 +427,17 @@ class ProviderGoogleGenAI(Provider):
if ts_bs64:
part.thought_signature = base64.b64decode(ts_bs64)
parts.append(part)
if not parts:
append_or_extend(gemini_contents, parts, types.ModelContent)
else:
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
if native_tool_enabled and "tool_calls" in message:
logger.warning(
"检测到启用Gemini原生工具且上下文中存在函数调用建议使用 /reset 重置上下文",
)
parts = [types.Part.from_text(text=" ")]
append_or_extend(gemini_contents, parts, types.ModelContent)
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool":
elif role == "tool" and not native_tool_enabled:
func_name = message.get("name", message["tool_call_id"])
part = types.Part.from_function_response(
name=func_name,
@@ -477,7 +501,7 @@ class ProviderGoogleGenAI(Provider):
) -> MessageChain:
"""处理内容部分并构建消息链"""
if not candidate.content:
logger.warning(f"Gemini candidate.content is empty: {candidate}")
logger.warning(f"收到的 candidate.content 为空: {candidate}")
if validate_output:
raise EmptyModelOutputError(
"Gemini candidate content is empty. "
@@ -490,22 +514,22 @@ class ProviderGoogleGenAI(Provider):
result_parts: list[types.Part] | None = candidate.content.parts
if finish_reason == types.FinishReason.SAFETY:
raise Exception("The model output failed Gemini platform safety checks.")
raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
if finish_reason in {
types.FinishReason.PROHIBITED_CONTENT,
types.FinishReason.SPII,
types.FinishReason.BLOCKLIST,
}:
raise Exception("The model output violates Gemini platform policy.")
raise Exception("模型生成内容违反 Gemini 平台政策")
# 防止旧版本SDK不存在IMAGE_SAFETY
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
if finish_reason == types.FinishReason.IMAGE_SAFETY:
raise Exception("The model output violates Gemini platform policy.")
raise Exception("模型生成内容违反 Gemini 平台政策")
if not result_parts:
logger.warning(f"Gemini candidate.content.parts is empty: {candidate}")
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
if validate_output:
raise EmptyModelOutputError(
"Gemini candidate content parts are empty. "
@@ -612,19 +636,15 @@ class ProviderGoogleGenAI(Provider):
logger.debug(f"genai result: {result}")
if not result.candidates:
logger.error(
f"Gemini request failed: candidates is empty: {result}"
)
raise Exception("Gemini request failed: candidates is empty.")
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
raise Exception("请求失败, 返回的 candidates 为空。")
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
if temperature > 2:
raise Exception(
"Temperature exceeded the maximum value of 2, but Gemini recitation still occurred."
)
raise Exception("温度参数已超过最大值2仍然发生recitation")
temperature += 0.2
logger.warning(
f"Gemini recitation detected; increasing temperature to {temperature:.1f} and retrying...",
f"发生了recitation,正在提高温度至{temperature:.1f}重试...",
)
continue
@@ -635,13 +655,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} does not support system prompts; removing it automatically. This may affect persona settings.",
f"{model} 不支持 system prompt已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(
f"{model} does not support function calling; removing tools automatically."
)
logger.warning(f"{model} 不支持函数调用,已自动去除")
tools = None
elif (
"Multi-modal output is not supported" in e.message
@@ -650,7 +668,7 @@ class ProviderGoogleGenAI(Provider):
or "only supports text output" in e.message
):
logger.warning(
f"{model} does not support multimodal output; falling back to TEXT modality.",
f"{model} 不支持多模态输出,降级为文本模态",
)
modalities = ["TEXT"]
else:
@@ -701,13 +719,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} does not support system prompts; removing it automatically. This may affect persona settings.",
f"{model} 不支持 system prompt已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(
f"{model} does not support function calling; removing tools automatically."
)
logger.warning(f"{model} 不支持函数调用,已自动去除")
tools = None
else:
raise
@@ -722,10 +738,10 @@ class ProviderGoogleGenAI(Provider):
llm_response = LLMResponse("assistant", is_chunk=True)
if not chunk.candidates:
logger.warning(f"Gemini stream chunk has empty candidates: {chunk}")
logger.warning(f"收到的 chunk 中 candidates 为空: {chunk}")
continue
if not chunk.candidates[0].content:
logger.warning(f"Gemini stream chunk has empty content: {chunk}")
logger.warning(f"收到的 chunk 中 content 为空: {chunk}")
continue
if chunk.candidates[0].content.parts and any(
@@ -856,7 +872,7 @@ class ProviderGoogleGenAI(Provider):
continue
break
raise Exception("Gemini request failed.")
raise Exception("请求失败。")
async def text_chat_stream(
self,
@@ -931,7 +947,7 @@ class ProviderGoogleGenAI(Provider):
and m.name
]
except APIError as e:
raise Exception(f"Failed to fetch Gemini model list: {e.message}")
raise Exception(f"获取模型列表失败: {e.message}")
def get_current_key(self) -> str:
return self.chosen_api_key
@@ -958,7 +974,7 @@ class ProviderGoogleGenAI(Provider):
media_type="image",
)
if not image_data:
logger.warning("Image preprocessing returned no data; ignoring it.")
logger.warning("图片预处理结果为空,将忽略。")
return None
return {
"type": "image_url",
@@ -973,13 +989,11 @@ class ProviderGoogleGenAI(Provider):
strict=True,
)
except Exception as exc:
logger.warning(
"Audio preprocessing failed; ignoring it. Error: %s", exc
)
logger.warning("音频预处理失败,将忽略。错误: %s", exc)
return None
if not audio_data:
logger.warning("Audio preprocessing returned no data; ignoring it.")
logger.warning("音频预处理结果为空,将忽略。")
return None
return {
"type": "audio_url",
@@ -1015,9 +1029,7 @@ class ProviderGoogleGenAI(Provider):
if audio_part:
content_blocks.append(audio_part)
else:
raise ValueError(
f"Unsupported extra content part type: {type(part)}"
)
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
# 3. 图片内容
if image_urls:

View File

@@ -60,28 +60,12 @@ Common options:
| --- | --- |
| `-p, --port <PORT>` | Set the WebUI port. |
| `-r, --reload` | Enable plugin auto-reload for plugin development. |
| `--reset-password` | Reset the WebUI initial password on startup and print the new password in startup logs. |
Examples:
```bash
astrbot run --port 6185
astrbot run --reload
astrbot run --reset-password
```
If you forget the WebUI login password, run this from the AstrBot working directory:
```bash
astrbot run --reset-password
```
AstrBot regenerates the initial password during startup and prints it in startup logs. After logging in, change the password in the WebUI immediately.
When starting directly from source, you can also run:
```bash
python main.py --reset-password
```
## Config

View File

@@ -60,28 +60,12 @@ astrbot run
| --- | --- |
| `-p, --port <PORT>` | 指定 WebUI 端口。 |
| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 |
| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新密码。 |
示例:
```bash
astrbot run --port 6185
astrbot run --reload
astrbot run --reset-password
```
如果你忘记了 WebUI 登录密码,可以在 AstrBot 工作目录中执行:
```bash
astrbot run --reset-password
```
AstrBot 会在启动时重新生成初始密码,并在启动日志中打印。登录后请立即在 WebUI 中修改密码。
使用源码方式直接启动时,也可以执行:
```bash
python main.py --reset-password
```
## 配置

30
main.py
View File

@@ -9,28 +9,6 @@ import runtime_bootstrap
runtime_bootstrap.initialize_runtime_bootstrap()
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
def _apply_startup_env_flags(argv: list[str]) -> None:
"""Apply startup flags that must take effect before core imports.
Args:
argv: Command-line arguments excluding the executable name.
"""
if "-h" in argv or "--help" in argv:
return
startup_parser = argparse.ArgumentParser(add_help=False)
startup_parser.add_argument("--reset-password", action="store_true")
startup_args, _ = startup_parser.parse_known_args(argv)
if startup_args.reset_password:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
_apply_startup_env_flags(sys.argv[1:])
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
from astrbot.core.config.default import VERSION # noqa: E402
from astrbot.core.initial_loader import InitialLoader # noqa: E402
@@ -163,14 +141,6 @@ if __name__ == "__main__":
help="Specify the directory path for WebUI static files",
default=None,
)
parser.add_argument(
"--reset-password",
action="store_true",
help=(
"Reset the dashboard initial password on startup and print it in "
"startup logs"
),
)
args = parser.parse_args()
check_env()

View File

@@ -1,43 +0,0 @@
import os
import sys
from click.testing import CliRunner
from astrbot.cli.commands import cmd_run
def test_run_reset_password_sets_startup_env(monkeypatch, tmp_path):
(tmp_path / ".astrbot").touch()
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(cmd_run.DASHBOARD_RESET_PASSWORD_ENV, raising=False)
original_env = {
"ASTRBOT_CLI": os.environ.get("ASTRBOT_CLI"),
"ASTRBOT_ROOT": os.environ.get("ASTRBOT_ROOT"),
cmd_run.DASHBOARD_RESET_PASSWORD_ENV: os.environ.get(
cmd_run.DASHBOARD_RESET_PASSWORD_ENV
),
}
original_sys_path = list(sys.path)
called = False
async def fake_run_astrbot(astrbot_root):
nonlocal called
called = True
assert astrbot_root == tmp_path
assert os.environ[cmd_run.DASHBOARD_RESET_PASSWORD_ENV] == "1"
monkeypatch.setattr(cmd_run, "run_astrbot", fake_run_astrbot)
try:
result = CliRunner().invoke(cmd_run.run, ["--reset-password"])
finally:
for key, value in original_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
sys.path[:] = original_sys_path
assert result.exit_code == 0, result.output
assert called is True

View File

@@ -10,12 +10,7 @@ from unittest import mock
import pytest
from astrbot.core.utils.io import should_use_bundled_dashboard_dist
from main import (
DASHBOARD_RESET_PASSWORD_ENV,
_apply_startup_env_flags,
check_dashboard_files,
check_env,
)
from main import check_dashboard_files, check_env
class _version_info:
@@ -67,30 +62,6 @@ def test_check_env(monkeypatch):
check_env()
def test_apply_startup_env_flags_sets_reset_password_env(monkeypatch):
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
_apply_startup_env_flags(["--webui-dir", "/tmp/webui", "--reset-password"])
assert os.environ[DASHBOARD_RESET_PASSWORD_ENV] == "1"
def test_apply_startup_env_flags_ignores_unrelated_args(monkeypatch):
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
_apply_startup_env_flags(["--webui-dir", "/tmp/webui"])
assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ
def test_apply_startup_env_flags_does_not_reset_for_help(monkeypatch):
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
_apply_startup_env_flags(["--reset-password", "--help"])
assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ
def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):
astrbot_root = "/tmp/astrbot-root"
site_packages_path = "/tmp/astrbot-site-packages"

View File

@@ -10,8 +10,6 @@ from astrbot.core.config.default import DEFAULT_VALUE_MAP
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.utils.auth_password import (
DEFAULT_DASHBOARD_PASSWORD,
hash_dashboard_password,
hash_md5_dashboard_password,
validate_dashboard_password,
verify_dashboard_password,
)
@@ -322,53 +320,6 @@ class TestAstrBotConfigLoad:
config["dashboard"]["password"], generated_password
)
def test_reset_dashboard_password_env_rotates_existing_password(
self, temp_config_path, monkeypatch
):
"""Test startup reset flag rotates an already configured dashboard password."""
old_password = "OldPassword123"
default_config = {
"dashboard": {
"username": "astrbot",
"password": "",
"pbkdf2_password": "",
},
}
with open(temp_config_path, "w", encoding="utf-8") as f:
json.dump(
{
"dashboard": {
"username": "astrbot",
"password": hash_md5_dashboard_password(old_password),
"pbkdf2_password": hash_dashboard_password(old_password),
"password_change_required": False,
"password_storage_upgraded": True,
}
},
f,
)
monkeypatch.setenv("ASTRBOT_RESET_DASHBOARD_PASSWORD", "1")
config = AstrBotConfig(
config_path=temp_config_path,
default_config=default_config,
)
generated_password = getattr(config, "_generated_dashboard_password", None)
assert isinstance(generated_password, str)
assert config["dashboard"]["password_change_required"] is True
assert config["dashboard"]["password_storage_upgraded"] is True
assert "ASTRBOT_RESET_DASHBOARD_PASSWORD" not in os.environ
assert verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], generated_password
)
assert not verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], old_password
)
assert verify_dashboard_password(
config["dashboard"]["password"], generated_password
)
def test_legacy_astrbot_user_without_change_flag_keeps_legacy_password(
self, temp_config_path
):