From 59017aa9bb9d8d114c7f5345d831eac81e81ed43 Mon Sep 17 00:00:00 2001
From: "tao.jun" <61566027@163.com>
Date: Sun, 8 Feb 2026 13:03:32 +0800
Subject: [PATCH 001/120] feat(feishu): Add event handlers for reactions,
message read, and p2p chat events
- Register handlers for message reaction created events
- Register handlers for message read events
- Register handlers for bot entering p2p chat events
- Prevent error logs for these common but unprocessed events
- Import required event types from lark_oapi
---
nanobot/channels/feishu.py | 33 ++++++++++++++++++++++++++++++++-
1 file changed, 32 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 1c176a2..a4c7454 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -23,6 +23,8 @@ try:
CreateMessageReactionRequestBody,
Emoji,
P2ImMessageReceiveV1,
+ P2ImMessageMessageReadV1,
+ P2ImMessageReactionCreatedV1,
)
FEISHU_AVAILABLE = True
except ImportError:
@@ -82,12 +84,18 @@ class FeishuChannel(BaseChannel):
.log_level(lark.LogLevel.INFO) \
.build()
- # Create event handler (only register message receive, ignore other events)
+ # Create event handler (register message receive and other common events)
event_handler = lark.EventDispatcherHandler.builder(
self.config.encrypt_key or "",
self.config.verification_token or "",
).register_p2_im_message_receive_v1(
self._on_message_sync
+ ).register_p2_im_message_reaction_created_v1(
+ self._on_reaction_created
+ ).register_p2_im_message_message_read_v1(
+ self._on_message_read
+ ).register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(
+ self._on_bot_p2p_chat_entered
).build()
# Create WebSocket client for long connection
@@ -305,3 +313,26 @@ class FeishuChannel(BaseChannel):
except Exception as e:
logger.error(f"Error processing Feishu message: {e}")
+
+ def _on_reaction_created(self, data: "P2ImMessageReactionCreatedV1") -> None:
+ """
+ Handler for message reaction events.
+ We don't need to process these, but registering prevents error logs.
+ """
+ pass
+
+ def _on_message_read(self, data: "P2ImMessageMessageReadV1") -> None:
+ """
+ Handler for message read events.
+ We don't need to process these, but registering prevents error logs.
+ """
+ pass
+
+ def _on_bot_p2p_chat_entered(self, data: Any) -> None:
+ """
+ Handler for bot entering p2p chat events.
+ This is triggered when a user opens a chat with the bot.
+ We don't need to process these, but registering prevents error logs.
+ """
+ logger.debug("Bot entered p2p chat (user opened chat window)")
+ pass
From 4d6f02ec0dee02f532df2295e76ea7c6c2b15ae5 Mon Sep 17 00:00:00 2001
From: eric
Date: Sun, 1 Mar 2026 15:13:44 +0100
Subject: [PATCH 005/120] fix(feishu): split card messages when content has
multiple tables
Feishu rejects interactive cards that contain more than one table element
(API error 11310: card table number over limit).
Add FeishuChannel._split_elements_by_table_limit() which partitions the flat
card-elements list into groups of at most one table each. The send() method
now iterates over these groups and sends each as its own card message, so all
tables are delivered to the user instead of the entire message being dropped.
Single-table and table-free messages are unaffected (one card, same as before).
Fixes #1382
---
nanobot/channels/feishu.py | 40 ++++++++++--
tests/test_feishu_table_split.py | 104 +++++++++++++++++++++++++++++++
2 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 tests/test_feishu_table_split.py
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 0a0a5e4..9ab1d50 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -413,6 +413,34 @@ class FeishuChannel(BaseChannel):
elements.extend(self._split_headings(remaining))
return elements or [{"tag": "markdown", "content": content}]
+ @staticmethod
+ def _split_elements_by_table_limit(elements: list[dict], max_tables: int = 1) -> list[list[dict]]:
+ """Split card elements into groups with at most *max_tables* table elements each.
+
+ Feishu cards have a hard limit of one table per card (API error 11310).
+ When the rendered content contains multiple markdown tables each table is
+ placed in a separate card message so every table reaches the user.
+ """
+ if not elements:
+ return [[]]
+ groups: list[list[dict]] = []
+ current: list[dict] = []
+ table_count = 0
+ for el in elements:
+ if el.get("tag") == "table":
+ if table_count >= max_tables:
+ if current:
+ groups.append(current)
+ current = []
+ table_count = 0
+ current.append(el)
+ table_count += 1
+ else:
+ current.append(el)
+ if current:
+ groups.append(current)
+ return groups or [[]]
+
def _split_headings(self, content: str) -> list[dict]:
"""Split content by headings, converting headings to div elements."""
protected = content
@@ -653,11 +681,13 @@ class FeishuChannel(BaseChannel):
)
if msg.content and msg.content.strip():
- card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
- await loop.run_in_executor(
- None, self._send_message_sync,
- receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
- )
+ elements = self._build_card_elements(msg.content)
+ for chunk in self._split_elements_by_table_limit(elements):
+ card = {"config": {"wide_screen_mode": True}, "elements": chunk}
+ await loop.run_in_executor(
+ None, self._send_message_sync,
+ receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
+ )
except Exception as e:
logger.error("Error sending Feishu message: {}", e)
diff --git a/tests/test_feishu_table_split.py b/tests/test_feishu_table_split.py
new file mode 100644
index 0000000..af8fa16
--- /dev/null
+++ b/tests/test_feishu_table_split.py
@@ -0,0 +1,104 @@
+"""Tests for FeishuChannel._split_elements_by_table_limit.
+
+Feishu cards reject messages that contain more than one table element
+(API error 11310: card table number over limit). The helper splits a flat
+list of card elements into groups so that each group contains at most one
+table, allowing nanobot to send multiple cards instead of failing.
+"""
+
+from nanobot.channels.feishu import FeishuChannel
+
+
+def _md(text: str) -> dict:
+ return {"tag": "markdown", "content": text}
+
+
+def _table() -> dict:
+ return {
+ "tag": "table",
+ "columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
+ "rows": [{"c0": "v"}],
+ "page_size": 2,
+ }
+
+
+split = FeishuChannel._split_elements_by_table_limit
+
+
+def test_empty_list_returns_single_empty_group() -> None:
+ assert split([]) == [[]]
+
+
+def test_no_tables_returns_single_group() -> None:
+ els = [_md("hello"), _md("world")]
+ result = split(els)
+ assert result == [els]
+
+
+def test_single_table_stays_in_one_group() -> None:
+ els = [_md("intro"), _table(), _md("outro")]
+ result = split(els)
+ assert len(result) == 1
+ assert result[0] == els
+
+
+def test_two_tables_split_into_two_groups() -> None:
+ # Use different row values so the two tables are not equal
+ t1 = {
+ "tag": "table",
+ "columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
+ "rows": [{"c0": "table-one"}],
+ "page_size": 2,
+ }
+ t2 = {
+ "tag": "table",
+ "columns": [{"tag": "column", "name": "c0", "display_name": "B", "width": "auto"}],
+ "rows": [{"c0": "table-two"}],
+ "page_size": 2,
+ }
+ els = [_md("before"), t1, _md("between"), t2, _md("after")]
+ result = split(els)
+ assert len(result) == 2
+ # First group: text before table-1 + table-1
+ assert t1 in result[0]
+ assert t2 not in result[0]
+ # Second group: text between tables + table-2 + text after
+ assert t2 in result[1]
+ assert t1 not in result[1]
+
+
+def test_three_tables_split_into_three_groups() -> None:
+ tables = [
+ {"tag": "table", "columns": [], "rows": [{"c0": f"t{i}"}], "page_size": 1}
+ for i in range(3)
+ ]
+ els = tables[:]
+ result = split(els)
+ assert len(result) == 3
+ for i, group in enumerate(result):
+ assert tables[i] in group
+
+
+def test_leading_markdown_stays_with_first_table() -> None:
+ intro = _md("intro")
+ t = _table()
+ result = split([intro, t])
+ assert len(result) == 1
+ assert result[0] == [intro, t]
+
+
+def test_trailing_markdown_after_second_table() -> None:
+ t1, t2 = _table(), _table()
+ tail = _md("end")
+ result = split([t1, t2, tail])
+ assert len(result) == 2
+ assert result[1] == [t2, tail]
+
+
+def test_non_table_elements_before_first_table_kept_in_first_group() -> None:
+ head = _md("head")
+ t1, t2 = _table(), _table()
+ result = split([head, t1, t2])
+ # head + t1 in group 0; t2 in group 1
+ assert result[0] == [head, t1]
+ assert result[1] == [t2]
From ae788a17f8371de0d60b7b9d713bdb8261fa6cd2 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Mon, 2 Mar 2026 11:03:54 +0800
Subject: [PATCH 006/120] chore: add .worktrees to .gitignore
Co-Authored-By: Claude Opus 4.6
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index d7b930d..a543251 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.worktrees/
.assets
.env
*.pyc
From aed1ef55298433a963474d8fbdcf0b203945ffb5 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Mon, 2 Mar 2026 11:04:53 +0800
Subject: [PATCH 007/120] fix: add SIGTERM, SIGHUP handling and ignore SIGPIPE
- Add handler for SIGTERM to prevent "Terminated" message on Linux
- Add handler for SIGHUP for terminal closure handling
- Ignore SIGPIPE to prevent silent process termination
- Change os._exit(0) to sys.exit(0) for proper cleanup
Fixes issue #1365
Co-Authored-By: Claude Opus 4.6
---
nanobot/cli/commands.py | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2662e9f..8c53992 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -501,12 +501,17 @@ def agent(
else:
cli_channel, cli_chat_id = "cli", session_id
- def _exit_on_sigint(signum, frame):
+ def _handle_signal(signum, frame):
+ sig_name = signal.Signals(signum).name
_restore_terminal()
- console.print("\nGoodbye!")
- os._exit(0)
+ console.print(f"\nReceived {sig_name}, goodbye!")
+ sys.exit(0)
- signal.signal(signal.SIGINT, _exit_on_sigint)
+ signal.signal(signal.SIGINT, _handle_signal)
+ signal.signal(signal.SIGTERM, _handle_signal)
+ signal.signal(signal.SIGHUP, _handle_signal)
+ # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
+ signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive():
bus_task = asyncio.create_task(agent_loop.run())
From dba93ae83afe0a91a7fd6a79f40eb81ab30a5e14 Mon Sep 17 00:00:00 2001
From: yzchen
display."""
+
+ def dw(s: str) -> int:
+ return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)
+
+ rows: list[list[str]] = []
+ has_sep = False
+ for line in table_lines:
+ cells = [_strip_md(c) for c in line.strip().strip('|').split('|')]
+ if all(re.match(r'^:?-+:?$', c) for c in cells if c):
+ has_sep = True
+ continue
+ rows.append(cells)
+ if not rows or not has_sep:
+ return '\n'.join(table_lines)
+
+ ncols = max(len(r) for r in rows)
+ for r in rows:
+ r.extend([''] * (ncols - len(r)))
+ widths = [max(dw(r[c]) for r in rows) for c in range(ncols)]
+
+ def dr(cells: list[str]) -> str:
+ return ' '.join(f'{c}{" " * (w - dw(c))}' for c, w in zip(cells, widths))
+
+ out = [dr(rows[0])]
+ out.append(' '.join('─' * w for w in widths))
+ for row in rows[1:]:
+ out.append(dr(row))
+ return '\n'.join(out)
+
+
def _markdown_to_telegram_html(text: str) -> str:
"""
Convert markdown to Telegram-safe HTML.
@@ -34,6 +77,27 @@ def _markdown_to_telegram_html(text: str) -> str:
text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text)
+ # 1.5. Convert markdown tables to box-drawing (reuse code_block placeholders)
+ lines = text.split('\n')
+ rebuilt: list[str] = []
+ li = 0
+ while li < len(lines):
+ if re.match(r'^\s*\|.+\|', lines[li]):
+ tbl: list[str] = []
+ while li < len(lines) and re.match(r'^\s*\|.+\|', lines[li]):
+ tbl.append(lines[li])
+ li += 1
+ box = _render_table_box(tbl)
+ if box != '\n'.join(tbl):
+ code_blocks.append(box)
+ rebuilt.append(f"\x00CB{len(code_blocks) - 1}\x00")
+ else:
+ rebuilt.extend(tbl)
+ else:
+ rebuilt.append(lines[li])
+ li += 1
+ text = '\n'.join(rebuilt)
+
# 2. Extract and protect inline code
inline_codes: list[str] = []
def save_inline_code(m: re.Match) -> str:
@@ -255,42 +319,48 @@ class TelegramChannel(BaseChannel):
# Send text content
if msg.content and msg.content != "[empty message]":
is_progress = msg.metadata.get("_progress", False)
- draft_id = msg.metadata.get("message_id")
for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN):
- try:
- html = _markdown_to_telegram_html(chunk)
- if is_progress and draft_id:
- await self._app.bot.send_message_draft(
- chat_id=chat_id,
- draft_id=draft_id,
- text=html,
- parse_mode="HTML"
- )
- else:
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=html,
- parse_mode="HTML",
- reply_parameters=reply_params
- )
- except Exception as e:
- logger.warning("HTML parse failed, falling back to plain text: {}", e)
- try:
- if is_progress and draft_id:
- await self._app.bot.send_message_draft(
- chat_id=chat_id,
- draft_id=draft_id,
- text=chunk
- )
- else:
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=chunk,
- reply_parameters=reply_params
- )
- except Exception as e2:
- logger.error("Error sending Telegram message: {}", e2)
+ # Final response: simulate streaming via draft, then persist
+ if not is_progress:
+ await self._send_with_streaming(chat_id, chunk, reply_params)
+ else:
+ await self._send_text(chat_id, chunk, reply_params)
+
+ async def _send_text(self, chat_id: int, text: str, reply_params=None) -> None:
+ """Send a plain text message with HTML fallback."""
+ try:
+ html = _markdown_to_telegram_html(text)
+ await self._app.bot.send_message(
+ chat_id=chat_id, text=html, parse_mode="HTML",
+ reply_parameters=reply_params,
+ )
+ except Exception as e:
+ logger.warning("HTML parse failed, falling back to plain text: {}", e)
+ try:
+ await self._app.bot.send_message(
+ chat_id=chat_id, text=text, reply_parameters=reply_params,
+ )
+ except Exception as e2:
+ logger.error("Error sending Telegram message: {}", e2)
+
+ async def _send_with_streaming(self, chat_id: int, text: str, reply_params=None) -> None:
+ """Simulate streaming via send_message_draft, then persist with send_message."""
+ draft_id = int(time.time() * 1000) % (2**31)
+ try:
+ step = max(len(text) // 8, 40)
+ for i in range(step, len(text), step):
+ await self._app.bot.send_message_draft(
+ chat_id=chat_id, draft_id=draft_id, text=text[:i],
+ )
+ await asyncio.sleep(0.04)
+ await self._app.bot.send_message_draft(
+ chat_id=chat_id, draft_id=draft_id, text=text,
+ )
+ await asyncio.sleep(0.15)
+ except Exception:
+ pass
+ await self._send_text(chat_id, text, reply_params)
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
From 43022b17184070ce6b1a4fe487b27517238050d7 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar
Date: Fri, 6 Mar 2026 17:20:52 +0000
Subject: [PATCH 079/120] Fix unit test after updating error message
---
tests/test_azure_openai_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py
index df2cdc3..680ddf4 100644
--- a/tests/test_azure_openai_provider.py
+++ b/tests/test_azure_openai_provider.py
@@ -291,7 +291,7 @@ async def test_chat_connection_error():
result = await provider.chat(messages)
assert isinstance(result, LLMResponse)
- assert "Error calling Azure OpenAI: Connection failed" in result.content
+ assert "Error calling Azure OpenAI: Exception('Connection failed')" in result.content
assert result.finish_reason == "error"
From 7e4594e08dc74ab438d3d903a1fac6441a498615 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar
Date: Fri, 6 Mar 2026 18:12:46 +0000
Subject: [PATCH 080/120] Increase timeout for chat completion calls
---
nanobot/providers/azure_openai_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index fc8e950..6da37e7 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider):
)
try:
- async with httpx.AsyncClient() as client:
+ async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code != 200:
return LLMResponse(
From 73be53d4bd7e5ff7363644248ab47296959bd3c9 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar
Date: Fri, 6 Mar 2026 18:16:15 +0000
Subject: [PATCH 081/120] Add SSL verification
---
nanobot/providers/azure_openai_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index 6da37e7..3f325aa 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider):
)
try:
- async with httpx.AsyncClient(timeout=60.0) as client:
+ async with httpx.AsyncClient(timeout=60.0, verify=True) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code != 200:
return LLMResponse(
From 79f3ca4f12ffe6497f30958f4959e579b5d4434b Mon Sep 17 00:00:00 2001
From: Maciej Wojcik
Date: Fri, 6 Mar 2026 20:32:10 +0000
Subject: [PATCH 082/120] feat(cli): add workspace and config flags to agent
---
README.md | 14 ++++++
nanobot/cli/commands.py | 24 ++++++----
tests/test_commands.py | 97 ++++++++++++++++++++++++++++++++++++++++-
3 files changed, 125 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 0c49608..86869a2 100644
--- a/README.md
+++ b/README.md
@@ -710,6 +710,9 @@ nanobot provider login openai-codex
**3. Chat:**
```bash
nanobot agent -m "Hello!"
+
+# Target a specific workspace/config locally
+nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
@@ -917,6 +920,15 @@ Each instance has its own:
- Cron jobs storage (`workspace/cron/jobs.json`)
- Configuration (if using `--config`)
+To open a CLI session against one of these instances locally:
+
+```bash
+nanobot agent -w ~/.nanobot/botA -m "Hello from botA"
+nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json
+```
+
+> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
+
## CLI Reference
@@ -924,6 +936,8 @@ Each instance has its own:
|---------|-------------|
| `nanobot onboard` | Initialize config & workspace |
| `nanobot agent -m "..."` | Chat with the agent |
+| `nanobot agent -w ` | Chat against a specific workspace |
+| `nanobot agent -w -c ` | Chat against a specific workspace/config |
| `nanobot agent` | Interactive chat mode |
| `nanobot agent --no-markdown` | Show plain-text replies |
| `nanobot agent --logs` | Show runtime logs during chat |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 7d2c161..5987796 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -9,7 +9,6 @@ from pathlib import Path
# Force UTF-8 encoding for Windows console
if sys.platform == "win32":
- import locale
if sys.stdout.encoding != "utf-8":
os.environ["PYTHONIOENCODING"] = "utf-8"
# Re-open stdout/stderr with UTF-8 encoding
@@ -248,6 +247,17 @@ def _make_provider(config: Config):
)
+def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
+ """Load config and optionally override the active workspace."""
+ from nanobot.config.loader import load_config
+
+ config_path = Path(config) if config else None
+ loaded = load_config(config_path)
+ if workspace:
+ loaded.agents.defaults.workspace = workspace
+ return loaded
+
+
# ============================================================================
# Gateway / Server
# ============================================================================
@@ -264,7 +274,6 @@ def gateway(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
- from nanobot.config.loader import load_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -274,10 +283,7 @@ def gateway(
import logging
logging.basicConfig(level=logging.DEBUG)
- config_path = Path(config) if config else None
- config = load_config(config_path)
- if workspace:
- config.agents.defaults.workspace = workspace
+ config = _load_runtime_config(config, workspace)
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
sync_workspace_templates(config.workspace_path)
@@ -448,6 +454,8 @@ def gateway(
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
@@ -456,10 +464,10 @@ def agent(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
- from nanobot.config.loader import get_data_dir, load_config
+ from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
- config = load_config()
+ config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 044d113..46ee7d0 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,6 +1,6 @@
import shutil
from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from typer.testing import CliRunner
@@ -19,7 +19,7 @@ def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
- patch("nanobot.config.loader.load_config") as mock_lc, \
+ patch("nanobot.config.loader.load_config"), \
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data")
@@ -128,3 +128,96 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
+
+
+@pytest.fixture
+def mock_agent_runtime(tmp_path):
+ """Mock agent command dependencies for focused CLI tests."""
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "default-workspace")
+ data_dir = tmp_path / "data"
+
+ with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
+ patch("nanobot.config.loader.get_data_dir", return_value=data_dir), \
+ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
+ patch("nanobot.cli.commands._make_provider", return_value=object()), \
+ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
+ patch("nanobot.bus.queue.MessageBus"), \
+ patch("nanobot.cron.service.CronService"), \
+ patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
+
+ agent_loop = MagicMock()
+ agent_loop.channels_config = None
+ agent_loop.process_direct = AsyncMock(return_value="mock-response")
+ agent_loop.close_mcp = AsyncMock(return_value=None)
+ mock_agent_loop_cls.return_value = agent_loop
+
+ yield {
+ "config": config,
+ "load_config": mock_load_config,
+ "sync_templates": mock_sync_templates,
+ "agent_loop_cls": mock_agent_loop_cls,
+ "agent_loop": agent_loop,
+ "print_response": mock_print_response,
+ }
+
+
+def test_agent_help_shows_workspace_and_config_options():
+ result = runner.invoke(app, ["agent", "--help"])
+
+ assert result.exit_code == 0
+ assert "--workspace" in result.stdout
+ assert "-w" in result.stdout
+ assert "--config" in result.stdout
+ assert "-c" in result.stdout
+
+
+def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime):
+ result = runner.invoke(app, ["agent", "-m", "hello"])
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["load_config"].call_args.args == (None,)
+ assert mock_agent_runtime["sync_templates"].call_args.args == (
+ mock_agent_runtime["config"].workspace_path,
+ )
+ assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == (
+ mock_agent_runtime["config"].workspace_path
+ )
+ mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
+ mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True)
+
+
+def test_agent_uses_explicit_config_path(mock_agent_runtime):
+ config_path = Path("/tmp/agent-config.json")
+
+ result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+
+
+def test_agent_overrides_workspace_path(mock_agent_runtime):
+ workspace_path = Path("/tmp/agent-workspace")
+
+ result = runner.invoke(app, ["agent", "-m", "hello", "-w", str(workspace_path)])
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
+ assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
+ assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
+
+
+def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime):
+ config_path = Path("/tmp/agent-config.json")
+ workspace_path = Path("/tmp/agent-workspace")
+
+ result = runner.invoke(
+ app,
+ ["agent", "-m", "hello", "-c", str(config_path), "-w", str(workspace_path)],
+ )
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+ assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
+ assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
+ assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
From fdd161d7b2f65d1564b23c445eaef56f85665ce3 Mon Sep 17 00:00:00 2001
From: fat-operator
Date: Fri, 6 Mar 2026 23:36:54 +0000
Subject: [PATCH 083/120] Implemented image support for whatsapp
---
bridge/src/server.ts | 13 ++++++-
bridge/src/whatsapp.ts | 75 ++++++++++++++++++++++++++++++------
nanobot/channels/whatsapp.py | 34 +++++++++++++---
3 files changed, 102 insertions(+), 20 deletions(-)
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index 7d48f5e..ec5573a 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,6 +12,13 @@ interface SendCommand {
text: string;
}
+interface SendImageCommand {
+ type: 'send_image';
+ to: string;
+ imagePath: string;
+ caption?: string;
+}
+
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
@@ -72,7 +79,7 @@ export class BridgeServer {
ws.on('message', async (data) => {
try {
- const cmd = JSON.parse(data.toString()) as SendCommand;
+ const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
@@ -92,9 +99,11 @@ export class BridgeServer {
});
}
- private async handleCommand(cmd: SendCommand): Promise {
+ private async handleCommand(cmd: SendCommand | SendImageCommand): Promise {
if (cmd.type === 'send' && this.wa) {
await this.wa.sendMessage(cmd.to, cmd.text);
+ } else if (cmd.type === 'send_image' && this.wa) {
+ await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption);
}
}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 069d72b..d34100f 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -9,11 +9,17 @@ import makeWASocket, {
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
+ downloadMediaMessage,
+ extractMessageContent as baileysExtractMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
+import { writeFile, mkdir, readFile } from 'fs/promises';
+import { join } from 'path';
+import { homedir } from 'os';
+import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -24,6 +30,7 @@ export interface InboundMessage {
content: string;
timestamp: number;
isGroup: boolean;
+ media?: string[];
}
export interface WhatsAppClientOptions {
@@ -110,14 +117,21 @@ export class WhatsAppClient {
if (type !== 'notify') return;
for (const msg of messages) {
- // Skip own messages
if (msg.key.fromMe) continue;
-
- // Skip status updates
if (msg.key.remoteJid === 'status@broadcast') continue;
- const content = this.extractMessageContent(msg);
- if (!content) continue;
+ const unwrapped = baileysExtractMessageContent(msg.message);
+ if (!unwrapped) continue;
+
+ const content = this.getTextContent(unwrapped);
+ const mediaPaths: string[] = [];
+
+ if (unwrapped.imageMessage) {
+ const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined);
+ if (path) mediaPaths.push(path);
+ }
+
+ if (!content && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
@@ -125,18 +139,43 @@ export class WhatsAppClient {
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
- content,
+ content: content || '',
timestamp: msg.messageTimestamp as number,
isGroup,
+ ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
});
}
- private extractMessageContent(msg: any): string | null {
- const message = msg.message;
- if (!message) return null;
+ private async downloadImage(msg: any, mimetype?: string): Promise {
+ try {
+ const mediaDir = join(homedir(), '.nanobot', 'media');
+ await mkdir(mediaDir, { recursive: true });
+ const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
+
+ const mime = mimetype || 'image/jpeg';
+ const extMap: Record = {
+ 'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif',
+ 'image/webp': '.webp',
+ };
+ const ext = extMap[mime] || '.jpg';
+
+ const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
+ const filepath = join(mediaDir, filename);
+ await writeFile(filepath, buffer);
+
+ return filepath;
+ } catch (err) {
+ console.error('Failed to download image:', err);
+ return null;
+ }
+ }
+
+ private getTextContent(message: any): string | null {
// Text message
if (message.conversation) {
return message.conversation;
@@ -147,9 +186,9 @@ export class WhatsAppClient {
return message.extendedTextMessage.text;
}
- // Image with caption
- if (message.imageMessage?.caption) {
- return `[Image] ${message.imageMessage.caption}`;
+ // Image with optional caption
+ if (message.imageMessage) {
+ return message.imageMessage.caption || '';
}
// Video with caption
@@ -178,6 +217,18 @@ export class WhatsAppClient {
await this.sock.sendMessage(to, { text });
}
+ async sendImage(to: string, imagePath: string, caption?: string): Promise {
+ if (!this.sock) {
+ throw new Error('Not connected');
+ }
+
+ const buffer = await readFile(imagePath);
+ await this.sock.sendMessage(to, {
+ image: buffer,
+ caption: caption || undefined,
+ });
+ }
+
async disconnect(): Promise {
if (this.sock) {
this.sock.end(undefined);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 0d1ec7e..1a96753 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -83,12 +83,26 @@ class WhatsAppChannel(BaseChannel):
return
try:
- payload = {
- "type": "send",
- "to": msg.chat_id,
- "text": msg.content
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ # Send media files first
+ for media_path in (msg.media or []):
+ try:
+ payload = {
+ "type": "send_image",
+ "to": msg.chat_id,
+ "imagePath": media_path,
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ except Exception as e:
+ logger.error("Error sending WhatsApp media {}: {}", media_path, e)
+
+ # Send text message if there's content
+ if msg.content:
+ payload = {
+ "type": "send",
+ "to": msg.chat_id,
+ "text": msg.content
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
@@ -128,10 +142,18 @@ class WhatsAppChannel(BaseChannel):
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
content = "[Voice Message: Transcription not available for WhatsApp yet]"
+ # Extract media paths (images downloaded by the bridge)
+ media_paths = data.get("media") or []
+
+ # For image messages without caption, provide descriptive content
+ if not content and media_paths:
+ content = "[image]"
+
await self._handle_message(
sender_id=sender_id,
chat_id=sender, # Use full LID for replies
content=content,
+ media=media_paths,
metadata={
"message_id": message_id,
"timestamp": data.get("timestamp"),
From 8c2589753292936212593b463168c983cf573a14 Mon Sep 17 00:00:00 2001
From: fat-operator
Date: Fri, 6 Mar 2026 23:48:54 +0000
Subject: [PATCH 084/120] Remove image sending capabilities - cant be tested
---
bridge/package-lock.json | 1362 ++++++++++++++++++++++++++++++++++
bridge/src/server.ts | 13 +-
bridge/src/whatsapp.ts | 14 +-
nanobot/channels/whatsapp.py | 26 +-
4 files changed, 1371 insertions(+), 44 deletions(-)
create mode 100644 bridge/package-lock.json
diff --git a/bridge/package-lock.json b/bridge/package-lock.json
new file mode 100644
index 0000000..7847d20
--- /dev/null
+++ b/bridge/package-lock.json
@@ -0,0 +1,1362 @@
+{
+ "name": "nanobot-whatsapp-bridge",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "nanobot-whatsapp-bridge",
+ "version": "0.1.0",
+ "dependencies": {
+ "@whiskeysockets/baileys": "7.0.0-rc.9",
+ "pino": "^9.0.0",
+ "qrcode-terminal": "^0.12.0",
+ "ws": "^8.17.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.0",
+ "@types/ws": "^8.5.10",
+ "typescript": "^5.4.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@borewit/text-codec": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
+ "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@cacheable/memory": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
+ "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.4.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/node-cache": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
+ "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.3.1",
+ "hookified": "^1.14.0",
+ "keyv": "^5.5.5"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
+ "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.0",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@hapi/boom": {
+ "version": "9.1.4",
+ "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
+ "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+ "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "license": "MIT"
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+ "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+ "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@tokenizer/inflate": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
+ "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "token-types": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/long": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
+ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.37",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@whiskeysockets/baileys": {
+ "version": "7.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
+ "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/node-cache": "^1.4.0",
+ "@hapi/boom": "^9.1.3",
+ "async-mutex": "^0.5.0",
+ "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
+ "lru-cache": "^11.1.0",
+ "music-metadata": "^11.7.0",
+ "p-queue": "^9.0.0",
+ "pino": "^9.6",
+ "protobufjs": "^7.2.4",
+ "ws": "^8.13.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "audio-decode": "^2.1.3",
+ "jimp": "^1.6.0",
+ "link-preview-js": "^3.0.0",
+ "sharp": "*"
+ },
+ "peerDependenciesMeta": {
+ "audio-decode": {
+ "optional": true
+ },
+ "jimp": {
+ "optional": true
+ },
+ "link-preview-js": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/async-mutex": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
+ "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/cacheable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
+ "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.0.8",
+ "@cacheable/utils": "^2.4.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.6.0"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/curve25519-js": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
+ "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/file-type": {
+ "version": "21.3.0",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
+ "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/inflate": "^0.4.1",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.1",
+ "uint8array-extras": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
+ "node_modules/hashery": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
+ "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.14.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/libsignal": {
+ "name": "@whiskeysockets/libsignal-node",
+ "version": "2.0.1",
+ "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
+ "license": "GPL-3.0",
+ "dependencies": {
+ "curve25519-js": "^0.0.4",
+ "protobufjs": "6.8.8"
+ }
+ },
+ "node_modules/libsignal/node_modules/@types/node": {
+ "version": "10.17.60",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+ "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
+ "license": "MIT"
+ },
+ "node_modules/libsignal/node_modules/long": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/libsignal/node_modules/protobufjs": {
+ "version": "6.8.8",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
+ "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/long": "^4.0.0",
+ "@types/node": "^10.1.0",
+ "long": "^4.0.0"
+ },
+ "bin": {
+ "pbjs": "bin/pbjs",
+ "pbts": "bin/pbts"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/music-metadata": {
+ "version": "11.12.1",
+ "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
+ "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://buymeacoffee.com/borewit"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "file-type": "^21.3.0",
+ "media-typer": "^1.1.0",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.2",
+ "uint8array-extras": "^1.5.0",
+ "win-guid": "^0.2.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
+ "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pino": {
+ "version": "9.14.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+ "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/protobufjs": {
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
+ "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/node": ">=13.7.0",
+ "long": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/qified": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
+ "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.14.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qrcode-terminal": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
+ "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
+ "bin": {
+ "qrcode-terminal": "bin/qrcode-terminal.js"
+ }
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "peer": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/strtok3": {
+ "version": "10.3.4",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
+ "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
+ "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
+ "node_modules/token-types": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
+ "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uint8array-extras": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+ "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/win-guid": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
+ "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index ec5573a..7d48f5e 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,13 +12,6 @@ interface SendCommand {
text: string;
}
-interface SendImageCommand {
- type: 'send_image';
- to: string;
- imagePath: string;
- caption?: string;
-}
-
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
@@ -79,7 +72,7 @@ export class BridgeServer {
ws.on('message', async (data) => {
try {
- const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand;
+ const cmd = JSON.parse(data.toString()) as SendCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
@@ -99,11 +92,9 @@ export class BridgeServer {
});
}
- private async handleCommand(cmd: SendCommand | SendImageCommand): Promise {
+ private async handleCommand(cmd: SendCommand): Promise {
if (cmd.type === 'send' && this.wa) {
await this.wa.sendMessage(cmd.to, cmd.text);
- } else if (cmd.type === 'send_image' && this.wa) {
- await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption);
}
}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index d34100f..793e518 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -16,7 +16,7 @@ import makeWASocket, {
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
-import { writeFile, mkdir, readFile } from 'fs/promises';
+import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
@@ -217,18 +217,6 @@ export class WhatsAppClient {
await this.sock.sendMessage(to, { text });
}
- async sendImage(to: string, imagePath: string, caption?: string): Promise {
- if (!this.sock) {
- throw new Error('Not connected');
- }
-
- const buffer = await readFile(imagePath);
- await this.sock.sendMessage(to, {
- image: buffer,
- caption: caption || undefined,
- });
- }
-
async disconnect(): Promise {
if (this.sock) {
this.sock.end(undefined);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 1a96753..21793b7 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -83,26 +83,12 @@ class WhatsAppChannel(BaseChannel):
return
try:
- # Send media files first
- for media_path in (msg.media or []):
- try:
- payload = {
- "type": "send_image",
- "to": msg.chat_id,
- "imagePath": media_path,
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
- except Exception as e:
- logger.error("Error sending WhatsApp media {}: {}", media_path, e)
-
- # Send text message if there's content
- if msg.content:
- payload = {
- "type": "send",
- "to": msg.chat_id,
- "text": msg.content
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ payload = {
+ "type": "send",
+ "to": msg.chat_id,
+ "text": msg.content
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
From 067965da507853d29d9939095cd06d232871005f Mon Sep 17 00:00:00 2001
From: fat-operator
Date: Sat, 7 Mar 2026 00:13:38 +0000
Subject: [PATCH 085/120] Refactored from image support to generic media
---
bridge/package-lock.json | 1362 ----------------------------------
bridge/src/whatsapp.ts | 47 +-
nanobot/channels/whatsapp.py | 13 +-
3 files changed, 37 insertions(+), 1385 deletions(-)
delete mode 100644 bridge/package-lock.json
diff --git a/bridge/package-lock.json b/bridge/package-lock.json
deleted file mode 100644
index 7847d20..0000000
--- a/bridge/package-lock.json
+++ /dev/null
@@ -1,1362 +0,0 @@
-{
- "name": "nanobot-whatsapp-bridge",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "nanobot-whatsapp-bridge",
- "version": "0.1.0",
- "dependencies": {
- "@whiskeysockets/baileys": "7.0.0-rc.9",
- "pino": "^9.0.0",
- "qrcode-terminal": "^0.12.0",
- "ws": "^8.17.1"
- },
- "devDependencies": {
- "@types/node": "^20.14.0",
- "@types/ws": "^8.5.10",
- "typescript": "^5.4.0"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@borewit/text-codec": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
- "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@cacheable/memory": {
- "version": "2.0.8",
- "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
- "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/utils": "^2.4.0",
- "@keyv/bigmap": "^1.3.1",
- "hookified": "^1.15.1",
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@cacheable/node-cache": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
- "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
- "license": "MIT",
- "dependencies": {
- "cacheable": "^2.3.1",
- "hookified": "^1.14.0",
- "keyv": "^5.5.5"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@cacheable/utils": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
- "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.5.0",
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@hapi/boom": {
- "version": "9.1.4",
- "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
- "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "9.x.x"
- }
- },
- "node_modules/@hapi/hoek": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
- "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@img/colour": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
- "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "cpu": [
- "arm"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
- "cpu": [
- "ppc64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
- "cpu": [
- "riscv64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
- "cpu": [
- "s390x"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "cpu": [
- "arm"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
- "cpu": [
- "ppc64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
- "cpu": [
- "riscv64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
- "cpu": [
- "s390x"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@emnapi/runtime": "^1.7.0"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
- "cpu": [
- "ia32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@keyv/bigmap": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
- "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.4.0",
- "hookified": "^1.15.0"
- },
- "engines": {
- "node": ">= 18"
- },
- "peerDependencies": {
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@keyv/serialize": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
- "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
- "license": "MIT"
- },
- "node_modules/@pinojs/redact": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
- "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
- "license": "MIT"
- },
- "node_modules/@protobufjs/aspromise": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/base64": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
- }
- },
- "node_modules/@protobufjs/float": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/path": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/pool": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@tokenizer/inflate": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
- "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.3",
- "token-types": "^6.1.1"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@tokenizer/token": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
- "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
- "license": "MIT"
- },
- "node_modules/@types/long": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
- "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
- "license": "MIT"
- },
- "node_modules/@types/node": {
- "version": "20.19.37",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
- "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/@types/ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@whiskeysockets/baileys": {
- "version": "7.0.0-rc.9",
- "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
- "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "@cacheable/node-cache": "^1.4.0",
- "@hapi/boom": "^9.1.3",
- "async-mutex": "^0.5.0",
- "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
- "lru-cache": "^11.1.0",
- "music-metadata": "^11.7.0",
- "p-queue": "^9.0.0",
- "pino": "^9.6",
- "protobufjs": "^7.2.4",
- "ws": "^8.13.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "audio-decode": "^2.1.3",
- "jimp": "^1.6.0",
- "link-preview-js": "^3.0.0",
- "sharp": "*"
- },
- "peerDependenciesMeta": {
- "audio-decode": {
- "optional": true
- },
- "jimp": {
- "optional": true
- },
- "link-preview-js": {
- "optional": true
- }
- }
- },
- "node_modules/async-mutex": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
- "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/atomic-sleep": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/cacheable": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
- "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/memory": "^2.0.8",
- "@cacheable/utils": "^2.4.0",
- "hookified": "^1.15.0",
- "keyv": "^5.6.0",
- "qified": "^0.6.0"
- }
- },
- "node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/curve25519-js": {
- "version": "0.0.4",
- "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
- "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "license": "Apache-2.0",
- "peer": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eventemitter3": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
- "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
- "license": "MIT"
- },
- "node_modules/file-type": {
- "version": "21.3.0",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
- "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/inflate": "^0.4.1",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.1",
- "uint8array-extras": "^1.4.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/file-type?sponsor=1"
- }
- },
- "node_modules/hashery": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
- "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/hookified": {
- "version": "1.15.1",
- "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
- "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
- "license": "MIT"
- },
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause"
- },
- "node_modules/keyv": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
- "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
- "license": "MIT",
- "dependencies": {
- "@keyv/serialize": "^1.1.1"
- }
- },
- "node_modules/libsignal": {
- "name": "@whiskeysockets/libsignal-node",
- "version": "2.0.1",
- "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
- "license": "GPL-3.0",
- "dependencies": {
- "curve25519-js": "^0.0.4",
- "protobufjs": "6.8.8"
- }
- },
- "node_modules/libsignal/node_modules/@types/node": {
- "version": "10.17.60",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
- "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
- "license": "MIT"
- },
- "node_modules/libsignal/node_modules/long": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
- "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
- "license": "Apache-2.0"
- },
- "node_modules/libsignal/node_modules/protobufjs": {
- "version": "6.8.8",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
- "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/long": "^4.0.0",
- "@types/node": "^10.1.0",
- "long": "^4.0.0"
- },
- "bin": {
- "pbjs": "bin/pbjs",
- "pbts": "bin/pbts"
- }
- },
- "node_modules/long": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
- "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
- "license": "Apache-2.0"
- },
- "node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/media-typer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
- "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/music-metadata": {
- "version": "11.12.1",
- "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
- "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- },
- {
- "type": "buymeacoffee",
- "url": "https://buymeacoffee.com/borewit"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "content-type": "^1.0.5",
- "debug": "^4.4.3",
- "file-type": "^21.3.0",
- "media-typer": "^1.1.0",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.2",
- "uint8array-extras": "^1.5.0",
- "win-guid": "^0.2.1"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/on-exit-leak-free": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
- "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/p-queue": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
- "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
- "license": "MIT",
- "dependencies": {
- "eventemitter3": "^5.0.1",
- "p-timeout": "^7.0.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-timeout": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
- "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
- "license": "MIT",
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/pino": {
- "version": "9.14.0",
- "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
- "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
- "license": "MIT",
- "dependencies": {
- "@pinojs/redact": "^0.4.0",
- "atomic-sleep": "^1.0.0",
- "on-exit-leak-free": "^2.1.0",
- "pino-abstract-transport": "^2.0.0",
- "pino-std-serializers": "^7.0.0",
- "process-warning": "^5.0.0",
- "quick-format-unescaped": "^4.0.3",
- "real-require": "^0.2.0",
- "safe-stable-stringify": "^2.3.1",
- "sonic-boom": "^4.0.1",
- "thread-stream": "^3.0.0"
- },
- "bin": {
- "pino": "bin.js"
- }
- },
- "node_modules/pino-abstract-transport": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
- "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
- "license": "MIT",
- "dependencies": {
- "split2": "^4.0.0"
- }
- },
- "node_modules/pino-std-serializers": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
- "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
- "license": "MIT"
- },
- "node_modules/process-warning": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
- "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fastify"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/fastify"
- }
- ],
- "license": "MIT"
- },
- "node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/node": ">=13.7.0",
- "long": "^5.0.0"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/qified": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
- "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/qrcode-terminal": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
- "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
- "bin": {
- "qrcode-terminal": "bin/qrcode-terminal.js"
- }
- },
- "node_modules/quick-format-unescaped": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
- "license": "MIT"
- },
- "node_modules/real-require": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
- "license": "MIT",
- "engines": {
- "node": ">= 12.13.0"
- }
- },
- "node_modules/safe-stable-stringify": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
- "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "license": "ISC",
- "peer": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@img/colour": "^1.0.0",
- "detect-libc": "^2.1.2",
- "semver": "^7.7.3"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
- }
- },
- "node_modules/sonic-boom": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
- "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
- "license": "MIT",
- "dependencies": {
- "atomic-sleep": "^1.0.0"
- }
- },
- "node_modules/split2": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
- "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
- "license": "ISC",
- "engines": {
- "node": ">= 10.x"
- }
- },
- "node_modules/strtok3": {
- "version": "10.3.4",
- "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
- "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/token": "^0.3.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/thread-stream": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
- "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
- "license": "MIT",
- "dependencies": {
- "real-require": "^0.2.0"
- }
- },
- "node_modules/token-types": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
- "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "ieee754": "^1.2.1"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/uint8array-extras": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
- "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "license": "MIT"
- },
- "node_modules/win-guid": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
- "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
- "license": "MIT"
- },
- "node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- }
- }
-}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 793e518..279fe5a 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -127,7 +127,14 @@ export class WhatsAppClient {
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
- const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined);
+ const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
+ if (path) mediaPaths.push(path);
+ } else if (unwrapped.documentMessage) {
+ const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
+ unwrapped.documentMessage.fileName ?? undefined);
+ if (path) mediaPaths.push(path);
+ } else if (unwrapped.videoMessage) {
+ const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
}
@@ -148,29 +155,31 @@ export class WhatsAppClient {
});
}
- private async downloadImage(msg: any, mimetype?: string): Promise {
+ private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise {
try {
const mediaDir = join(homedir(), '.nanobot', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
- const mime = mimetype || 'image/jpeg';
- const extMap: Record = {
- 'image/jpeg': '.jpg',
- 'image/png': '.png',
- 'image/gif': '.gif',
- 'image/webp': '.webp',
- };
- const ext = extMap[mime] || '.jpg';
+ let outFilename: string;
+ if (fileName) {
+ // Documents have a filename — use it with a unique prefix to avoid collisions
+ const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
+ outFilename = prefix + fileName;
+ } else {
+ const mime = mimetype || 'application/octet-stream';
+ // Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf")
+ const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
+ outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
+ }
- const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
- const filepath = join(mediaDir, filename);
+ const filepath = join(mediaDir, outFilename);
await writeFile(filepath, buffer);
return filepath;
} catch (err) {
- console.error('Failed to download image:', err);
+ console.error('Failed to download media:', err);
return null;
}
}
@@ -191,14 +200,14 @@ export class WhatsAppClient {
return message.imageMessage.caption || '';
}
- // Video with caption
- if (message.videoMessage?.caption) {
- return `[Video] ${message.videoMessage.caption}`;
+ // Video with optional caption
+ if (message.videoMessage) {
+ return message.videoMessage.caption || '';
}
- // Document with caption
- if (message.documentMessage?.caption) {
- return `[Document] ${message.documentMessage.caption}`;
+ // Document with optional caption
+ if (message.documentMessage) {
+ return message.documentMessage.caption || '';
}
// Voice/Audio message
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 21793b7..1307716 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -2,6 +2,7 @@
import asyncio
import json
+import mimetypes
from collections import OrderedDict
from loguru import logger
@@ -128,12 +129,16 @@ class WhatsAppChannel(BaseChannel):
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
content = "[Voice Message: Transcription not available for WhatsApp yet]"
- # Extract media paths (images downloaded by the bridge)
+ # Extract media paths (images/documents/videos downloaded by the bridge)
media_paths = data.get("media") or []
- # For image messages without caption, provide descriptive content
- if not content and media_paths:
- content = "[image]"
+ # Build content tags matching Telegram's pattern: [image: /path] or [file: /path]
+ if media_paths:
+ for p in media_paths:
+ mime, _ = mimetypes.guess_type(p)
+ media_type = "image" if mime and mime.startswith("image/") else "file"
+ media_tag = f"[{media_type}: {p}]"
+ content = f"{content}\n{media_tag}" if content else media_tag
await self._handle_message(
sender_id=sender_id,
From e3810573568d6ea269f5d9ebfaa39623ad2ea30c Mon Sep 17 00:00:00 2001
From: 04cb <0x04cb@gmail.com>
Date: Sat, 7 Mar 2026 08:31:15 +0800
Subject: [PATCH 086/120] Fix tool_call_id length error for GitHub Copilot
provider
GitHub Copilot and some other providers have a 64-character limit on
tool_call_id. When switching from providers that generate longer IDs
(such as OpenAI Codex), this caused validation errors.
This fix truncates tool_call_id to 64 characters by preserving the first
32 and last 32 characters to maintain uniqueness while respecting the
provider's limit.
Fixes #1554
---
nanobot/providers/litellm_provider.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 620424e..767c8da 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -169,6 +169,8 @@ class LiteLLMProvider(LLMProvider):
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
+ # GitHub Copilot and some other providers have a 64-character limit on tool_call_id
+ MAX_TOOL_CALL_ID_LENGTH = 64
allowed = _ALLOWED_MSG_KEYS | extra_keys
sanitized = []
for msg in messages:
@@ -176,6 +178,13 @@ class LiteLLMProvider(LLMProvider):
# Strict providers require "content" even when assistant only has tool_calls
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
+ # Truncate tool_call_id if it exceeds the provider's limit
+ # This can happen when switching from providers that generate longer IDs
+ if "tool_call_id" in clean and clean["tool_call_id"]:
+ tool_call_id = clean["tool_call_id"]
+ if isinstance(tool_call_id, str) and len(tool_call_id) > MAX_TOOL_CALL_ID_LENGTH:
+ # Preserve first 32 chars and last 32 chars to maintain uniqueness
+ clean["tool_call_id"] = tool_call_id[:32] + tool_call_id[-32:]
sanitized.append(clean)
return sanitized
From 64112eb9ba985bae151b2c40a1760886823b5747 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 03:06:19 +0000
Subject: [PATCH 087/120] fix(whatsapp): avoid dropping media-only messages
---
README.md | 4 ++++
bridge/src/whatsapp.ts | 9 +++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 0c49608..d2a1c59 100644
--- a/README.md
+++ b/README.md
@@ -420,6 +420,10 @@ nanobot channels login
nanobot gateway
```
+> WhatsApp bridge updates are not applied automatically for existing installations.
+> If you upgrade nanobot and need the latest WhatsApp bridge, run:
+> `rm -rf ~/.nanobot/bridge && nanobot channels login`
+
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 279fe5a..b91bacc 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -124,21 +124,26 @@ export class WhatsAppClient {
if (!unwrapped) continue;
const content = this.getTextContent(unwrapped);
+ let fallbackContent: string | null = null;
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
+ fallbackContent = '[Image]';
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.documentMessage) {
+ fallbackContent = '[Document]';
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
unwrapped.documentMessage.fileName ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.videoMessage) {
+ fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
}
- if (!content && mediaPaths.length === 0) continue;
+ const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
+ if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
@@ -146,7 +151,7 @@ export class WhatsAppClient {
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
- content: content || '',
+ content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
From c94ac351f1a285e22fc0796a54a11d2821755ab6 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 03:30:36 +0000
Subject: [PATCH 088/120] fix(litellm): normalize tool call ids
---
nanobot/providers/litellm_provider.py | 40 +++++++++++++++++++++------
1 file changed, 32 insertions(+), 8 deletions(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 767c8da..2fd6c18 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -1,5 +1,6 @@
"""LiteLLM provider implementation for multi-provider support."""
+import hashlib
import os
import secrets
import string
@@ -166,25 +167,48 @@ class LiteLLMProvider(LLMProvider):
return _ANTHROPIC_EXTRA_KEYS
return frozenset()
+ @staticmethod
+ def _normalize_tool_call_id(tool_call_id: Any) -> Any:
+ """Normalize tool_call_id to a provider-safe 9-char alphanumeric form."""
+ if not isinstance(tool_call_id, str):
+ return tool_call_id
+ if len(tool_call_id) == 9 and tool_call_id.isalnum():
+ return tool_call_id
+ return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
+
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
- # GitHub Copilot and some other providers have a 64-character limit on tool_call_id
- MAX_TOOL_CALL_ID_LENGTH = 64
allowed = _ALLOWED_MSG_KEYS | extra_keys
sanitized = []
+ id_map: dict[str, str] = {}
+
+ def map_id(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value))
+
for msg in messages:
clean = {k: v for k, v in msg.items() if k in allowed}
# Strict providers require "content" even when assistant only has tool_calls
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
- # Truncate tool_call_id if it exceeds the provider's limit
- # This can happen when switching from providers that generate longer IDs
+
+ # Keep assistant tool_calls[].id and tool tool_call_id in sync after
+ # shortening, otherwise strict providers reject the broken linkage.
+ if isinstance(clean.get("tool_calls"), list):
+ normalized_tool_calls = []
+ for tc in clean["tool_calls"]:
+ if not isinstance(tc, dict):
+ normalized_tool_calls.append(tc)
+ continue
+ tc_clean = dict(tc)
+ tc_clean["id"] = map_id(tc_clean.get("id"))
+ normalized_tool_calls.append(tc_clean)
+ clean["tool_calls"] = normalized_tool_calls
+
if "tool_call_id" in clean and clean["tool_call_id"]:
- tool_call_id = clean["tool_call_id"]
- if isinstance(tool_call_id, str) and len(tool_call_id) > MAX_TOOL_CALL_ID_LENGTH:
- # Preserve first 32 chars and last 32 chars to maintain uniqueness
- clean["tool_call_id"] = tool_call_id[:32] + tool_call_id[-32:]
+ clean["tool_call_id"] = map_id(clean["tool_call_id"])
sanitized.append(clean)
return sanitized
From 576ad12ef16fbf7813fb88d46f43c48a23d98ed8 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 03:57:57 +0000
Subject: [PATCH 089/120] fix(azure): sanitize messages and handle temperature
---
nanobot/providers/azure_openai_provider.py | 25 +++++++++-
nanobot/providers/base.py | 14 ++++++
nanobot/providers/litellm_provider.py | 10 +---
tests/test_azure_openai_provider.py | 57 +++++++++++++++++++---
4 files changed, 89 insertions(+), 17 deletions(-)
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index 3f325aa..bd79b00 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -11,6 +11,8 @@ import json_repair
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+_AZURE_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"})
+
class AzureOpenAIProvider(LLMProvider):
"""
@@ -67,19 +69,38 @@ class AzureOpenAIProvider(LLMProvider):
"x-session-affinity": uuid.uuid4().hex, # For cache locality
}
+ @staticmethod
+ def _supports_temperature(
+ deployment_name: str,
+ reasoning_effort: str | None = None,
+ ) -> bool:
+ """Return True when temperature is likely supported for this deployment."""
+ if reasoning_effort:
+ return False
+ name = deployment_name.lower()
+ return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
+
def _prepare_request_payload(
self,
+ deployment_name: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
+ temperature: float = 0.7,
reasoning_effort: str | None = None,
) -> dict[str, Any]:
"""Prepare the request payload with Azure OpenAI 2024-10-21 compliance."""
payload: dict[str, Any] = {
- "messages": self._sanitize_empty_content(messages),
+ "messages": self._sanitize_request_messages(
+ self._sanitize_empty_content(messages),
+ _AZURE_MSG_KEYS,
+ ),
"max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens
}
+ if self._supports_temperature(deployment_name, reasoning_effort):
+ payload["temperature"] = temperature
+
if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort
@@ -116,7 +137,7 @@ class AzureOpenAIProvider(LLMProvider):
url = self._build_chat_url(deployment_name)
headers = self._build_headers()
payload = self._prepare_request_payload(
- messages, tools, max_tokens, reasoning_effort
+ deployment_name, messages, tools, max_tokens, temperature, reasoning_effort
)
try:
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index 55bd805..0f73544 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -87,6 +87,20 @@ class LLMProvider(ABC):
result.append(msg)
return result
+ @staticmethod
+ def _sanitize_request_messages(
+ messages: list[dict[str, Any]],
+ allowed_keys: frozenset[str],
+ ) -> list[dict[str, Any]]:
+ """Keep only provider-safe message keys and normalize assistant content."""
+ sanitized = []
+ for msg in messages:
+ clean = {k: v for k, v in msg.items() if k in allowed_keys}
+ if clean.get("role") == "assistant" and "content" not in clean:
+ clean["content"] = None
+ sanitized.append(clean)
+ return sanitized
+
@abstractmethod
async def chat(
self,
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 2fd6c18..cb67635 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -180,7 +180,7 @@ class LiteLLMProvider(LLMProvider):
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
allowed = _ALLOWED_MSG_KEYS | extra_keys
- sanitized = []
+ sanitized = LLMProvider._sanitize_request_messages(messages, allowed)
id_map: dict[str, str] = {}
def map_id(value: Any) -> Any:
@@ -188,12 +188,7 @@ class LiteLLMProvider(LLMProvider):
return value
return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value))
- for msg in messages:
- clean = {k: v for k, v in msg.items() if k in allowed}
- # Strict providers require "content" even when assistant only has tool_calls
- if clean.get("role") == "assistant" and "content" not in clean:
- clean["content"] = None
-
+ for clean in sanitized:
# Keep assistant tool_calls[].id and tool tool_call_id in sync after
# shortening, otherwise strict providers reject the broken linkage.
if isinstance(clean.get("tool_calls"), list):
@@ -209,7 +204,6 @@ class LiteLLMProvider(LLMProvider):
if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_id(clean["tool_call_id"])
- sanitized.append(clean)
return sanitized
async def chat(
diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py
index 680ddf4..77f36d4 100644
--- a/tests/test_azure_openai_provider.py
+++ b/tests/test_azure_openai_provider.py
@@ -1,9 +1,9 @@
"""Test Azure OpenAI provider implementation (updated for model-based deployment names)."""
-import asyncio
-import pytest
from unittest.mock import AsyncMock, Mock, patch
+import pytest
+
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.base import LLMResponse
@@ -89,22 +89,65 @@ def test_prepare_request_payload():
)
messages = [{"role": "user", "content": "Hello"}]
- payload = provider._prepare_request_payload(messages, max_tokens=1500)
+ payload = provider._prepare_request_payload("gpt-4o", messages, max_tokens=1500, temperature=0.8)
assert payload["messages"] == messages
assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens
- assert "temperature" not in payload # Temperature not included in payload
+ assert payload["temperature"] == 0.8
assert "tools" not in payload
# Test with tools
tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}]
- payload_with_tools = provider._prepare_request_payload(messages, tools=tools)
+ payload_with_tools = provider._prepare_request_payload("gpt-4o", messages, tools=tools)
assert payload_with_tools["tools"] == tools
assert payload_with_tools["tool_choice"] == "auto"
# Test with reasoning_effort
- payload_with_reasoning = provider._prepare_request_payload(messages, reasoning_effort="medium")
+ payload_with_reasoning = provider._prepare_request_payload(
+ "gpt-5-chat", messages, reasoning_effort="medium"
+ )
assert payload_with_reasoning["reasoning_effort"] == "medium"
+ assert "temperature" not in payload_with_reasoning
+
+
+def test_prepare_request_payload_sanitizes_messages():
+ """Test Azure payload strips non-standard message keys before sending."""
+ provider = AzureOpenAIProvider(
+ api_key="test-key",
+ api_base="https://test-resource.openai.azure.com",
+ default_model="gpt-4o",
+ )
+
+ messages = [
+ {
+ "role": "assistant",
+ "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}],
+ "reasoning_content": "hidden chain-of-thought",
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_123",
+ "name": "x",
+ "content": "ok",
+ "extra_field": "should be removed",
+ },
+ ]
+
+ payload = provider._prepare_request_payload("gpt-4o", messages)
+
+ assert payload["messages"] == [
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_123",
+ "name": "x",
+ "content": "ok",
+ },
+ ]
@pytest.mark.asyncio
@@ -349,7 +392,7 @@ if __name__ == "__main__":
# Test payload preparation
messages = [{"role": "user", "content": "Test"}]
- payload = provider._prepare_request_payload(messages, max_tokens=1000)
+ payload = provider._prepare_request_payload("gpt-4o-deployment", messages, max_tokens=1000)
assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format
print("✅ Payload preparation works correctly")
From c81d32c40f6c2baac34c73eec53c731fb00ae6d2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 04:07:25 +0000
Subject: [PATCH 090/120] fix(discord): handle attachment reply fallback
---
nanobot/channels/discord.py | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 8672327..0187c62 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -84,20 +84,31 @@ class DiscordChannel(BaseChannel):
headers = {"Authorization": f"Bot {self.config.token}"}
try:
+ sent_media = False
+ failed_media: list[str] = []
+
# Send file attachments first
for media_path in msg.media or []:
- await self._send_file(url, headers, media_path, reply_to=msg.reply_to)
+ if await self._send_file(url, headers, media_path, reply_to=msg.reply_to):
+ sent_media = True
+ else:
+ failed_media.append(Path(media_path).name)
# Send text content
chunks = split_message(msg.content or "", MAX_MESSAGE_LEN)
+ if not chunks and failed_media and not sent_media:
+ chunks = split_message(
+ "\n".join(f"[attachment: {name} - send failed]" for name in failed_media),
+ MAX_MESSAGE_LEN,
+ )
if not chunks:
return
for i, chunk in enumerate(chunks):
payload: dict[str, Any] = {"content": chunk}
- # Only set reply reference on the first chunk (if no media was sent)
- if i == 0 and msg.reply_to and not msg.media:
+ # Let the first successful attachment carry the reply if present.
+ if i == 0 and msg.reply_to and not sent_media:
payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False}
From c3f2d1b01dbf02ec278c7714a85e7cc07f38280c Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 05:28:12 +0000
Subject: [PATCH 091/120] fix(tools): narrow parameter auto-casting
---
nanobot/agent/tools/base.py | 113 ++++++++++------------------------
tests/test_tool_validation.py | 54 +++++-----------
2 files changed, 48 insertions(+), 119 deletions(-)
diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py
index fb34fe8..06f5bdd 100644
--- a/nanobot/agent/tools/base.py
+++ b/nanobot/agent/tools/base.py
@@ -3,8 +3,6 @@
from abc import ABC, abstractmethod
from typing import Any
-from loguru import logger
-
class Tool(ABC):
"""
@@ -55,11 +53,7 @@ class Tool(ABC):
pass
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
- """
- Attempt to cast parameters to match schema types.
- Returns modified params dict. If casting fails, returns original value
- and logs a debug message, allowing validation to catch the error.
- """
+ """Apply safe schema-driven casts before validation."""
schema = self.parameters or {}
if schema.get("type", "object") != "object":
return params
@@ -86,91 +80,44 @@ class Tool(ABC):
"""Cast a single value according to schema."""
target_type = schema.get("type")
- # Already correct type
- # Note: check bool before int since bool is subclass of int
if target_type == "boolean" and isinstance(val, bool):
return val
if target_type == "integer" and isinstance(val, int) and not isinstance(val, bool):
return val
- # For array/object, don't early-return - we need to recurse into contents
- if target_type in self._TYPE_MAP and target_type not in (
- "boolean",
- "integer",
- "array",
- "object",
- ):
+ if target_type in self._TYPE_MAP and target_type not in ("boolean", "integer", "array", "object"):
expected = self._TYPE_MAP[target_type]
if isinstance(val, expected):
return val
- # Attempt casting
- try:
- if target_type == "integer":
- if isinstance(val, bool):
- # Don't silently convert bool to int
- raise ValueError("Cannot cast bool to integer")
- if isinstance(val, str):
- return int(val)
- if isinstance(val, (int, float)):
- return int(val)
+ if target_type == "integer" and isinstance(val, str):
+ try:
+ return int(val)
+ except ValueError:
+ return val
- elif target_type == "number":
- if isinstance(val, bool):
- # Don't silently convert bool to number
- raise ValueError("Cannot cast bool to number")
- if isinstance(val, str):
- return float(val)
- if isinstance(val, (int, float)):
- return float(val)
+ if target_type == "number" and isinstance(val, str):
+ try:
+ return float(val)
+ except ValueError:
+ return val
- elif target_type == "string":
- # Preserve None vs empty string distinction
- if val is None:
- return val
- return str(val)
+ if target_type == "string":
+ return val if val is None else str(val)
- elif target_type == "boolean":
- if isinstance(val, str):
- val_lower = val.lower()
- if val_lower in ("true", "1", "yes"):
- return True
- elif val_lower in ("false", "0", "no"):
- return False
- # For other strings, raise error to let validation handle it
- raise ValueError(f"Cannot convert string '{val}' to boolean")
- return bool(val)
+ if target_type == "boolean" and isinstance(val, str):
+ val_lower = val.lower()
+ if val_lower in ("true", "1", "yes"):
+ return True
+ if val_lower in ("false", "0", "no"):
+ return False
+ return val
- elif target_type == "array":
- if isinstance(val, list):
- # Recursively cast array items if schema defines items
- if "items" in schema:
- return [self._cast_value(item, schema["items"]) for item in val]
- return val
- # Preserve None vs empty array distinction
- if val is None:
- return val
- # Empty string → empty array
- if val == "":
- return []
- # Don't auto-wrap single values, let validation catch the error
- raise ValueError(f"Cannot convert {type(val).__name__} to array")
+ if target_type == "array" and isinstance(val, list):
+ item_schema = schema.get("items")
+ return [self._cast_value(item, item_schema) for item in val] if item_schema else val
- elif target_type == "object":
- if isinstance(val, dict):
- return self._cast_object(val, schema)
- # Preserve None vs empty object distinction
- if val is None:
- return val
- # Empty string → empty object
- if val == "":
- return {}
- # Cannot cast to object
- raise ValueError(f"Cannot cast {type(val).__name__} to object")
-
- except (ValueError, TypeError) as e:
- # Log failed casts for debugging, return original value
- # Let validation catch the error
- logger.debug("Failed to cast value %r to %s: %s", val, target_type, e)
+ if target_type == "object" and isinstance(val, dict):
+ return self._cast_object(val, schema)
return val
@@ -185,7 +132,13 @@ class Tool(ABC):
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
t, label = schema.get("type"), path or "parameter"
- if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]):
+ if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)):
+ return [f"{label} should be integer"]
+ if t == "number" and (
+ not isinstance(val, self._TYPE_MAP[t]) or isinstance(val, bool)
+ ):
+ return [f"{label} should be number"]
+ if t in self._TYPE_MAP and t not in ("integer", "number") and not isinstance(val, self._TYPE_MAP[t]):
return [f"{label} should be {t}"]
errors = []
diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py
index 6fb87ea..c2b4b6a 100644
--- a/tests/test_tool_validation.py
+++ b/tests/test_tool_validation.py
@@ -210,9 +210,10 @@ def test_cast_params_bool_not_cast_to_int() -> None:
"properties": {"count": {"type": "integer"}},
}
)
- # Bool input should remain bool (validation will catch it)
result = tool.cast_params({"count": True})
- assert result["count"] is True # Not cast to 1
+ assert result["count"] is True
+ errors = tool.validate_params(result)
+ assert any("count should be integer" in e for e in errors)
def test_cast_params_preserves_empty_string() -> None:
@@ -283,6 +284,18 @@ def test_cast_params_invalid_string_to_number() -> None:
assert result["rate"] == "not_a_number"
+def test_validate_params_bool_not_accepted_as_number() -> None:
+ """Booleans should not pass number validation."""
+ tool = CastTestTool(
+ {
+ "type": "object",
+ "properties": {"rate": {"type": "number"}},
+ }
+ )
+ errors = tool.validate_params({"rate": False})
+ assert any("rate should be number" in e for e in errors)
+
+
def test_cast_params_none_values() -> None:
"""Test None handling for different types."""
tool = CastTestTool(
@@ -324,40 +337,3 @@ def test_cast_params_single_value_not_auto_wrapped_to_array() -> None:
assert result["items"] == 5 # Not wrapped to [5]
result = tool.cast_params({"items": "text"})
assert result["items"] == "text" # Not wrapped to ["text"]
-
-
-def test_cast_params_empty_string_to_array() -> None:
- """Empty string should convert to empty array."""
- tool = CastTestTool(
- {
- "type": "object",
- "properties": {"items": {"type": "array"}},
- }
- )
- result = tool.cast_params({"items": ""})
- assert result["items"] == []
-
-
-def test_cast_params_empty_string_to_object() -> None:
- """Empty string should convert to empty object."""
- tool = CastTestTool(
- {
- "type": "object",
- "properties": {"config": {"type": "object"}},
- }
- )
- result = tool.cast_params({"config": ""})
- assert result["config"] == {}
-
-
-def test_cast_params_float_to_int() -> None:
- """Float values should be cast to integers."""
- tool = CastTestTool(
- {
- "type": "object",
- "properties": {"count": {"type": "integer"}},
- }
- )
- result = tool.cast_params({"count": 42.7})
- assert result["count"] == 42
- assert isinstance(result["count"], int)
From 215360113fa967f197301352416d694697b049ba Mon Sep 17 00:00:00 2001
From: chengyongru
Date: Sat, 7 Mar 2026 16:19:55 +0800
Subject: [PATCH 092/120] feat(feishu): add audio transcription support using
Groq Whisper
---
nanobot/channels/feishu.py | 15 ++++++++++++++-
nanobot/channels/manager.py | 3 ++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 8f69c09..611c95e 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -244,9 +244,10 @@ class FeishuChannel(BaseChannel):
name = "feishu"
- def __init__(self, config: FeishuConfig, bus: MessageBus):
+ def __init__(self, config: FeishuConfig, bus: MessageBus, groq_api_key: str = ""):
super().__init__(config, bus)
self.config: FeishuConfig = config
+ self.groq_api_key = groq_api_key
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
@@ -909,6 +910,18 @@ class FeishuChannel(BaseChannel):
file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id)
if file_path:
media_paths.append(file_path)
+
+ # Transcribe audio using Groq Whisper
+ if msg_type == "audio" and file_path and self.groq_api_key:
+ try:
+ from nanobot.providers.transcription import GroqTranscriptionProvider
+ transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
+ transcription = await transcriber.transcribe(file_path)
+ if transcription:
+ content_text = f"[transcription: {transcription}]"
+ except Exception as e:
+ logger.warning("Failed to transcribe audio: {}", e)
+
content_parts.append(content_text)
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 7d7d110..51539dd 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -74,7 +74,8 @@ class ChannelManager:
try:
from nanobot.channels.feishu import FeishuChannel
self.channels["feishu"] = FeishuChannel(
- self.config.channels.feishu, self.bus
+ self.config.channels.feishu, self.bus,
+ groq_api_key=self.config.providers.groq.api_key,
)
logger.info("Feishu channel enabled")
except ImportError as e:
From cf76011c1aae1b397361f85751443b36b6418e79 Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sat, 7 Mar 2026 17:09:59 +0800
Subject: [PATCH 093/120] fix: hide reasoning_content from user progress
updates
---
nanobot/agent/loop.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 7f129a2..56a91c1 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -202,9 +202,10 @@ class AgentLoop:
if response.has_tool_calls:
if on_progress:
+ # Only show stripped content and thinking blocks in progress, not reasoning_content
+ # reasoning_content is internal thinking and should not be shown to users
thoughts = [
self._strip_think(response.content),
- response.reasoning_content,
*(
f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}"
for b in (response.thinking_blocks or [])
From 44327d6457f87884954bde79c25415ba69134a41 Mon Sep 17 00:00:00 2001
From: Gleb
Date: Sat, 7 Mar 2026 12:38:52 +0200
Subject: [PATCH 094/120] fix(telegram): added "stop" command handler, fixed
stop command
---
nanobot/channels/telegram.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index aaa24e7..c83edd3 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -197,6 +197,7 @@ class TelegramChannel(BaseChannel):
# Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
+ self._app.add_handler(CommandHandler("stop", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help))
# Add message handler for text, photos, voice, documents
From 43fc59da0073f760b27221990cd5bc294682239f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 14:53:14 +0000
Subject: [PATCH 095/120] fix: hide internal reasoning in progress
---
nanobot/agent/loop.py | 16 +++------------
tests/test_message_tool_suppress.py | 30 +++++++++++++++++++++++++++++
2 files changed, 33 insertions(+), 13 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 56a91c1..ca9a06e 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -202,19 +202,9 @@ class AgentLoop:
if response.has_tool_calls:
if on_progress:
- # Only show stripped content and thinking blocks in progress, not reasoning_content
- # reasoning_content is internal thinking and should not be shown to users
- thoughts = [
- self._strip_think(response.content),
- *(
- f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}"
- for b in (response.thinking_blocks or [])
- if isinstance(b, dict) and "signature" in b
- ),
- ]
- combined_thoughts = "\n\n".join(filter(None, thoughts))
- if combined_thoughts:
- await on_progress(combined_thoughts)
+ thought = self._strip_think(response.content)
+ if thought:
+ await on_progress(thought)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py
index 26b8a16..f5e65c9 100644
--- a/tests/test_message_tool_suppress.py
+++ b/tests/test_message_tool_suppress.py
@@ -86,6 +86,36 @@ class TestMessageToolSuppressLogic:
assert result is not None
assert "Hello" in result.content
+ @pytest.mark.asyncio
+ async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None:
+ loop = _make_loop(tmp_path)
+ tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"})
+ calls = iter([
+ LLMResponse(
+ content="Visiblehidden ",
+ tool_calls=[tool_call],
+ reasoning_content="secret reasoning",
+ thinking_blocks=[{"signature": "sig", "thought": "secret thought"}],
+ ),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.execute = AsyncMock(return_value="ok")
+
+ progress: list[tuple[str, bool]] = []
+
+ async def on_progress(content: str, *, tool_hint: bool = False) -> None:
+ progress.append((content, tool_hint))
+
+ final_content, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert final_content == "Done"
+ assert progress == [
+ ("Visible", False),
+ ('read_file("foo.txt")', True),
+ ]
+
class TestMessageToolTurnTracking:
From a9f3552d6e7cdb441c0bc376605d06d83ab5ee2a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 15:11:09 +0000
Subject: [PATCH 096/120] test(telegram): cover proxy request initialization
---
tests/test_telegram_channel.py | 107 +++++++++++++++++++++++++++++++++
1 file changed, 107 insertions(+)
create mode 100644 tests/test_telegram_channel.py
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
new file mode 100644
index 0000000..3bacf96
--- /dev/null
+++ b/tests/test_telegram_channel.py
@@ -0,0 +1,107 @@
+from types import SimpleNamespace
+
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.telegram import TelegramChannel
+from nanobot.config.schema import TelegramConfig
+
+
+class _FakeHTTPXRequest:
+ instances: list["_FakeHTTPXRequest"] = []
+
+ def __init__(self, **kwargs) -> None:
+ self.kwargs = kwargs
+ self.__class__.instances.append(self)
+
+
+class _FakeUpdater:
+ def __init__(self, on_start_polling) -> None:
+ self._on_start_polling = on_start_polling
+
+ async def start_polling(self, **kwargs) -> None:
+ self._on_start_polling()
+
+
+class _FakeBot:
+ async def get_me(self):
+ return SimpleNamespace(username="nanobot_test")
+
+ async def set_my_commands(self, commands) -> None:
+ self.commands = commands
+
+
+class _FakeApp:
+ def __init__(self, on_start_polling) -> None:
+ self.bot = _FakeBot()
+ self.updater = _FakeUpdater(on_start_polling)
+ self.handlers = []
+ self.error_handlers = []
+
+ def add_error_handler(self, handler) -> None:
+ self.error_handlers.append(handler)
+
+ def add_handler(self, handler) -> None:
+ self.handlers.append(handler)
+
+ async def initialize(self) -> None:
+ pass
+
+ async def start(self) -> None:
+ pass
+
+
+class _FakeBuilder:
+ def __init__(self, app: _FakeApp) -> None:
+ self.app = app
+ self.token_value = None
+ self.request_value = None
+ self.get_updates_request_value = None
+
+ def token(self, token: str):
+ self.token_value = token
+ return self
+
+ def request(self, request):
+ self.request_value = request
+ return self
+
+ def get_updates_request(self, request):
+ self.get_updates_request_value = request
+ return self
+
+ def proxy(self, _proxy):
+ raise AssertionError("builder.proxy should not be called when request is set")
+
+ def get_updates_proxy(self, _proxy):
+ raise AssertionError("builder.get_updates_proxy should not be called when request is set")
+
+ def build(self):
+ return self.app
+
+
+@pytest.mark.asyncio
+async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None:
+ config = TelegramConfig(
+ enabled=True,
+ token="123:abc",
+ allow_from=["*"],
+ proxy="http://127.0.0.1:7890",
+ )
+ bus = MessageBus()
+ channel = TelegramChannel(config, bus)
+ app = _FakeApp(lambda: setattr(channel, "_running", False))
+ builder = _FakeBuilder(app)
+
+ monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest)
+ monkeypatch.setattr(
+ "nanobot.channels.telegram.Application",
+ SimpleNamespace(builder=lambda: builder),
+ )
+
+ await channel.start()
+
+ assert len(_FakeHTTPXRequest.instances) == 1
+ assert _FakeHTTPXRequest.instances[0].kwargs["proxy"] == config.proxy
+ assert builder.request_value is _FakeHTTPXRequest.instances[0]
+ assert builder.get_updates_request_value is _FakeHTTPXRequest.instances[0]
From 26670d3e8042746e4ce0feaaa3761aae7a97b436 Mon Sep 17 00:00:00 2001
From: shawn_wxn
Date: Fri, 6 Mar 2026 19:08:44 +0800
Subject: [PATCH 097/120] feat(dingtalk): add support for group chat messages
---
nanobot/channels/dingtalk.py | 32 +++++++++++++++++++++++++-------
1 file changed, 25 insertions(+), 7 deletions(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 8d02fa6..8e2a2be 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -70,6 +70,13 @@ class NanobotDingTalkHandler(CallbackHandler):
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
+ # Extract conversation info
+ conversation_type = message.data.get("conversationType")
+ conversation_id = message.data.get("conversationId") or message.data.get("openConversationId")
+
+ if conversation_type == "2" and conversation_id:
+ sender_id = f"group:{conversation_id}"
+
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking).
@@ -301,14 +308,25 @@ class DingTalkChannel(BaseChannel):
logger.warning("DingTalk HTTP client not initialized, cannot send")
return False
- url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
headers = {"x-acs-dingtalk-access-token": token}
- payload = {
- "robotCode": self.config.client_id,
- "userIds": [chat_id],
- "msgKey": msg_key,
- "msgParam": json.dumps(msg_param, ensure_ascii=False),
- }
+ if chat_id.startswith("group:"):
+ # Group chat
+ url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ payload = {
+ "robotCode": self.config.client_id,
+ "openConversationId": chat_id[6:], # Remove "group:" prefix,
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps(msg_param, ensure_ascii=False),
+ }
+ else:
+ # Private chat
+ url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+ payload = {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": msg_key,
+ "msgParam": json.dumps(msg_param, ensure_ascii=False),
+ }
try:
resp = await self._http.post(url, json=payload, headers=headers)
From caa2aa596dbaabb121af618e00635d47d1126f02 Mon Sep 17 00:00:00 2001
From: shawn_wxn
Date: Fri, 6 Mar 2026 19:08:59 +0800
Subject: [PATCH 098/120] fix(dingtalk): correct msgKey parameter for group
messages
---
nanobot/channels/dingtalk.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 8e2a2be..bd6a8c2 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -315,7 +315,7 @@ class DingTalkChannel(BaseChannel):
payload = {
"robotCode": self.config.client_id,
"openConversationId": chat_id[6:], # Remove "group:" prefix,
- "msgKey": "sampleMarkdown",
+ "msgKey": "msg_key",
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
else:
From 73991779b3bc82dcd39c0b9b6b189577380c7b1a Mon Sep 17 00:00:00 2001
From: shawn_wxn
Date: Fri, 6 Mar 2026 19:58:22 +0800
Subject: [PATCH 099/120] fix(dingtalk): use msg_key variable instead of
hardcoded
---
nanobot/channels/dingtalk.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index bd6a8c2..78ca6c9 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -315,7 +315,7 @@ class DingTalkChannel(BaseChannel):
payload = {
"robotCode": self.config.client_id,
"openConversationId": chat_id[6:], # Remove "group:" prefix,
- "msgKey": "msg_key",
+ "msgKey": msg_key,
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
else:
From 4e25ac5c82f8210bb4acf18bf0abd9e5f47841d2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 16:07:57 +0000
Subject: [PATCH 100/120] test(dingtalk): cover group reply routing
---
nanobot/channels/dingtalk.py | 35 ++++++++++++------
tests/test_dingtalk_channel.py | 66 ++++++++++++++++++++++++++++++++++
2 files changed, 91 insertions(+), 10 deletions(-)
create mode 100644 tests/test_dingtalk_channel.py
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 78ca6c9..3c301a9 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -70,19 +70,24 @@ class NanobotDingTalkHandler(CallbackHandler):
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
- # Extract conversation info
conversation_type = message.data.get("conversationType")
- conversation_id = message.data.get("conversationId") or message.data.get("openConversationId")
-
- if conversation_type == "2" and conversation_id:
- sender_id = f"group:{conversation_id}"
+ conversation_id = (
+ message.data.get("conversationId")
+ or message.data.get("openConversationId")
+ )
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking).
# Store reference to prevent GC before task completes.
task = asyncio.create_task(
- self.channel._on_message(content, sender_id, sender_name)
+ self.channel._on_message(
+ content,
+ sender_id,
+ sender_name,
+ conversation_type,
+ conversation_id,
+ )
)
self.channel._background_tasks.add(task)
task.add_done_callback(self.channel._background_tasks.discard)
@@ -102,8 +107,8 @@ class DingTalkChannel(BaseChannel):
Uses WebSocket to receive events via `dingtalk-stream` SDK.
Uses direct HTTP API to send messages (SDK is mainly for receiving).
- Note: Currently only supports private (1:1) chat. Group messages are
- received but replies are sent back as private messages to the sender.
+ Supports both private (1:1) and group chats.
+ Group chat_id is stored with a "group:" prefix to route replies back.
"""
name = "dingtalk"
@@ -435,7 +440,14 @@ class DingTalkChannel(BaseChannel):
f"[Attachment send failed: {filename}]",
)
- async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None:
+ async def _on_message(
+ self,
+ content: str,
+ sender_id: str,
+ sender_name: str,
+ conversation_type: str | None = None,
+ conversation_id: str | None = None,
+ ) -> None:
"""Handle incoming message (called by NanobotDingTalkHandler).
Delegates to BaseChannel._handle_message() which enforces allow_from
@@ -443,13 +455,16 @@ class DingTalkChannel(BaseChannel):
"""
try:
logger.info("DingTalk inbound: {} from {}", content, sender_name)
+ is_group = conversation_type == "2" and conversation_id
+ chat_id = f"group:{conversation_id}" if is_group else sender_id
await self._handle_message(
sender_id=sender_id,
- chat_id=sender_id, # For private chat, chat_id == sender_id
+ chat_id=chat_id,
content=str(content),
metadata={
"sender_name": sender_name,
"platform": "dingtalk",
+ "conversation_type": conversation_type,
},
)
except Exception as e:
diff --git a/tests/test_dingtalk_channel.py b/tests/test_dingtalk_channel.py
new file mode 100644
index 0000000..7595a33
--- /dev/null
+++ b/tests/test_dingtalk_channel.py
@@ -0,0 +1,66 @@
+from types import SimpleNamespace
+
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.dingtalk import DingTalkChannel
+from nanobot.config.schema import DingTalkConfig
+
+
+class _FakeResponse:
+ def __init__(self, status_code: int = 200, json_body: dict | None = None) -> None:
+ self.status_code = status_code
+ self._json_body = json_body or {}
+ self.text = "{}"
+
+ def json(self) -> dict:
+ return self._json_body
+
+
+class _FakeHttp:
+ def __init__(self) -> None:
+ self.calls: list[dict] = []
+
+ async def post(self, url: str, json=None, headers=None):
+ self.calls.append({"url": url, "json": json, "headers": headers})
+ return _FakeResponse()
+
+
+@pytest.mark.asyncio
+async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
+ config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
+ bus = MessageBus()
+ channel = DingTalkChannel(config, bus)
+
+ await channel._on_message(
+ "hello",
+ sender_id="user1",
+ sender_name="Alice",
+ conversation_type="2",
+ conversation_id="conv123",
+ )
+
+ msg = await bus.consume_inbound()
+ assert msg.sender_id == "user1"
+ assert msg.chat_id == "group:conv123"
+ assert msg.metadata["conversation_type"] == "2"
+
+
+@pytest.mark.asyncio
+async def test_group_send_uses_group_messages_api() -> None:
+ config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
+ channel = DingTalkChannel(config, MessageBus())
+ channel._http = _FakeHttp()
+
+ ok = await channel._send_batch_message(
+ "token",
+ "group:conv123",
+ "sampleMarkdown",
+ {"text": "hello", "title": "Nanobot Reply"},
+ )
+
+ assert ok is True
+ call = channel._http.calls[0]
+ assert call["url"] == "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ assert call["json"]["openConversationId"] == "conv123"
+ assert call["json"]["msgKey"] == "sampleMarkdown"
From 057927cd24871c73ecd46e47291ec0aa4ac3a2ce Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 16:36:12 +0000
Subject: [PATCH 101/120] fix(auth): prevent allowlist bypass via sender_id
token splitting
---
nanobot/channels/base.py | 5 +----
nanobot/channels/telegram.py | 19 +++++++++++++++++++
tests/test_base_channel.py | 25 +++++++++++++++++++++++++
tests/test_telegram_channel.py | 15 +++++++++++++++
4 files changed, 60 insertions(+), 4 deletions(-)
create mode 100644 tests/test_base_channel.py
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index b38fcaf..dc53ba4 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -66,10 +66,7 @@ class BaseChannel(ABC):
return False
if "*" in allow_list:
return True
- sender_str = str(sender_id)
- return sender_str in allow_list or any(
- p in allow_list for p in sender_str.split("|") if p
- )
+ return str(sender_id) in allow_list
async def _handle_message(
self,
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 81cf0ca..501a3c1 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -179,6 +179,25 @@ class TelegramChannel(BaseChannel):
self._media_group_tasks: dict[str, asyncio.Task] = {}
self._message_threads: dict[tuple[str, int], int] = {}
+ def is_allowed(self, sender_id: str) -> bool:
+ """Preserve Telegram's legacy id|username allowlist matching."""
+ if super().is_allowed(sender_id):
+ return True
+
+ allow_list = getattr(self.config, "allow_from", [])
+ if not allow_list or "*" in allow_list:
+ return False
+
+ sender_str = str(sender_id)
+ if sender_str.count("|") != 1:
+ return False
+
+ sid, username = sender_str.split("|", 1)
+ if not sid.isdigit() or not username:
+ return False
+
+ return sid in allow_list or username in allow_list
+
async def start(self) -> None:
"""Start the Telegram bot with long polling."""
if not self.config.token:
diff --git a/tests/test_base_channel.py b/tests/test_base_channel.py
new file mode 100644
index 0000000..5d10d4e
--- /dev/null
+++ b/tests/test_base_channel.py
@@ -0,0 +1,25 @@
+from types import SimpleNamespace
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+
+
+class _DummyChannel(BaseChannel):
+ name = "dummy"
+
+ async def start(self) -> None:
+ return None
+
+ async def stop(self) -> None:
+ return None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ return None
+
+
+def test_is_allowed_requires_exact_match() -> None:
+ channel = _DummyChannel(SimpleNamespace(allow_from=["allow@email.com"]), MessageBus())
+
+ assert channel.is_allowed("allow@email.com") is True
+ assert channel.is_allowed("attacker|allow@email.com") is False
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
index acd2a96..88c3f54 100644
--- a/tests/test_telegram_channel.py
+++ b/tests/test_telegram_channel.py
@@ -131,6 +131,21 @@ def test_get_extension_falls_back_to_original_filename() -> None:
assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz"
+def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None:
+ channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus())
+
+ assert channel.is_allowed("12345|carol") is True
+ assert channel.is_allowed("99999|alice") is True
+ assert channel.is_allowed("67890|bob") is True
+
+
+def test_is_allowed_rejects_invalid_legacy_telegram_sender_shapes() -> None:
+ channel = TelegramChannel(TelegramConfig(allow_from=["alice"]), MessageBus())
+
+ assert channel.is_allowed("attacker|alice|extra") is False
+ assert channel.is_allowed("not-a-number|alice") is False
+
+
@pytest.mark.asyncio
async def test_send_progress_keeps_message_in_topic() -> None:
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
From 3ca89d7821a0eccfc4e66b11e511fd4565c4f6b1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 01:42:30 +0000
Subject: [PATCH 102/120] docs: update nanobot news
---
README.md | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 03f042a..3c20adb 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,20 @@
## 📢 News
+- **2026-03-07** 🚀 Azure OpenAI, WhatsApp media, Discord attachments, QQ group chats, and lots of Telegram/Feishu polish.
+- **2026-03-06** 🪄 Lighter provider loading, smarter message/media handling, and more robust memory and CLI compatibility.
+- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, multi-instance gateway runs, and broader channel reliability fixes.
+- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and a fresh round of test, Cron, and validation reliability fixes.
+- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal session saves, and stronger Cron scheduling guards.
+- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
+- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, Feishu rich-text parsing, and more cleanup across the codebase.
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
+
+
+Earlier news
+
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
@@ -30,10 +41,6 @@
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
-
-
-Earlier news
-
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
From 822d2311e0c4eee4a51fe2c62a89bc543f027458 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 01:44:06 +0000
Subject: [PATCH 103/120] docs: update nanobot march news
---
README.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 3c20adb..18770dc 100644
--- a/README.md
+++ b/README.md
@@ -20,13 +20,13 @@
## 📢 News
-- **2026-03-07** 🚀 Azure OpenAI, WhatsApp media, Discord attachments, QQ group chats, and lots of Telegram/Feishu polish.
-- **2026-03-06** 🪄 Lighter provider loading, smarter message/media handling, and more robust memory and CLI compatibility.
-- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, multi-instance gateway runs, and broader channel reliability fixes.
-- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and a fresh round of test, Cron, and validation reliability fixes.
-- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal session saves, and stronger Cron scheduling guards.
+- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
+- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
+- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
+- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
+- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
-- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, Feishu rich-text parsing, and more cleanup across the codebase.
+- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
From 20dfaa5d34968cf8d3f19a180e053f145a7dfad3 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 02:58:25 +0000
Subject: [PATCH 104/120] refactor: unify instance path resolution and preserve
workspace override
---
README.md | 192 ++++++-----------------------------
bridge/src/whatsapp.ts | 3 +-
nanobot/channels/discord.py | 3 +-
nanobot/channels/feishu.py | 4 +-
nanobot/channels/matrix.py | 6 +-
nanobot/channels/mochat.py | 4 +-
nanobot/channels/telegram.py | 6 +-
nanobot/cli/commands.py | 27 +++--
nanobot/config/__init__.py | 26 ++++-
nanobot/config/loader.py | 8 --
nanobot/config/paths.py | 55 ++++++++++
nanobot/session/manager.py | 3 +-
nanobot/utils/__init__.py | 4 +-
nanobot/utils/helpers.py | 12 ---
tests/test_commands.py | 97 +++++++++++++++++-
tests/test_config_paths.py | 42 ++++++++
16 files changed, 282 insertions(+), 210 deletions(-)
create mode 100644 nanobot/config/paths.py
create mode 100644 tests/test_config_paths.py
diff --git a/README.md b/README.md
index fdbd5cf..5bd11f8 100644
--- a/README.md
+++ b/README.md
@@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
## Multiple Instances
-Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage.
+Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run.
### Quick Start
@@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json
nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
```
-### Complete Isolation
+### Path Resolution
-When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation:
+When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
-| Component | Isolation | Example |
-|-----------|-----------|---------|
-| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` |
-| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` |
-| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` |
-| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` |
-| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` |
+| Component | Resolved From | Example |
+|-----------|---------------|---------|
+| **Config** | `--config` path | `~/.nanobot-A/config.json` |
+| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
+| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
+| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
-### Setup Example
+### How It Works
-**1. Create directory structure for each instance:**
+- `--config` selects which config file to load
+- By default, the workspace comes from `agents.defaults.workspace` in that config
+- If you pass `--workspace`, it overrides the workspace from the config file
-```bash
-# Instance A
-mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media}
-cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json
+### Minimal Setup
-# Instance B
-mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media}
-cp ~/.nanobot/config.json ~/.nanobot-discord/config.json
-```
+1. Copy your base config into a new instance directory.
+2. Set a different `agents.defaults.workspace` for that instance.
+3. Start the instance with `--config`.
-**2. Configure each instance:**
+Example config:
-Edit `~/.nanobot-telegram/config.json`:
```json
{
"agents": {
@@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`:
}
```
-Edit `~/.nanobot-discord/config.json`:
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.nanobot-discord/workspace",
- "model": "anthropic/claude-opus-4"
- }
- },
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_DISCORD_BOT_TOKEN"
- }
- },
- "gateway": {
- "port": 18791
- }
-}
-```
-
-**3. Start instances:**
+Start separate instances:
```bash
-# Terminal 1
nanobot gateway --config ~/.nanobot-telegram/config.json
-
-# Terminal 2
nanobot gateway --config ~/.nanobot-discord/config.json
```
-### Use Cases
-
-- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc.
-- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously
-- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent)
-- **Multi-Tenant**: Serve multiple users/teams with isolated configurations
-
-### Management Scripts
-
-For production deployments, create management scripts for each instance:
+Override workspace for one-off runs when needed:
```bash
-#!/bin/bash
-# manage-telegram.sh
-
-INSTANCE_NAME="telegram"
-CONFIG_FILE="$HOME/.nanobot-telegram/config.json"
-LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log"
-
-case "$1" in
- start)
- nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 &
- echo "Started $INSTANCE_NAME instance (PID: $!)"
- ;;
- stop)
- pkill -f "nanobot gateway.*$CONFIG_FILE"
- echo "Stopped $INSTANCE_NAME instance"
- ;;
- restart)
- $0 stop
- sleep 2
- $0 start
- ;;
- status)
- pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null
- if [ $? -eq 0 ]; then
- echo "$INSTANCE_NAME instance is running"
- else
- echo "$INSTANCE_NAME instance is not running"
- fi
- ;;
- *)
- echo "Usage: $0 {start|stop|restart|status}"
- exit 1
- ;;
-esac
+nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
```
-### systemd Service (Linux)
+### Common Use Cases
-For automatic startup and crash recovery, create a systemd service for each instance:
-
-```ini
-# ~/.config/systemd/user/nanobot-telegram.service
-[Unit]
-Description=Nanobot Telegram Instance
-After=network.target
-
-[Service]
-Type=simple
-ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json
-Restart=always
-RestartSec=10
-
-[Install]
-WantedBy=default.target
-```
-
-Enable and start:
-```bash
-systemctl --user daemon-reload
-systemctl --user enable --now nanobot-telegram
-systemctl --user enable --now nanobot-discord
-```
-
-### launchd Service (macOS)
-
-Create a plist file for each instance:
-
-```xml
-
-
-
-
-
- Label
- com.nanobot.telegram
-
- ProgramArguments
-
- /path/to/nanobot
- gateway
- --config
- /Users/yourname/.nanobot-telegram/config.json
-
-
- RunAtLoad
-
-
- KeepAlive
-
-
- StandardOutPath
- /Users/yourname/.nanobot-telegram/logs/stdout.log
-
- StandardErrorPath
- /Users/yourname/.nanobot-telegram/logs/stderr.log
-
-
-```
-
-Load the service:
-```bash
-launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist
-launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist
-```
+- Run separate bots for Telegram, Discord, Feishu, and other platforms
+- Keep testing and production instances isolated
+- Use different models or providers for different teams
+- Serve multiple tenants with separate configs and runtime data
### Notes
-- Each instance must use a different port (default: 18790)
-- Instances are completely independent — no shared state or cross-talk
-- You can run different LLM models, providers, and channel configurations per instance
-- Memory, sessions, and cron jobs are fully isolated between instances
+- Each instance must use a different port if they run at the same time
+- Use a different workspace per instance if you want isolated memory, sessions, and skills
+- `--workspace` overrides the workspace defined in the config file
+- Cron jobs and runtime media/state are derived from the config directory
## CLI Reference
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index b91bacc..f0485bd 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
-import { homedir } from 'os';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -162,7 +161,7 @@ export class WhatsAppClient {
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise {
try {
- const mediaDir = join(homedir(), '.nanobot', 'media');
+ const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 0187c62..2ee4f77 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -12,6 +12,7 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import DiscordConfig
from nanobot.utils.helpers import split_message
@@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel):
content_parts = [content] if content else []
media_paths: list[str] = []
- media_dir = Path.home() / ".nanobot" / "media"
+ media_dir = get_media_dir("discord")
for attachment in payload.get("attachments") or []:
url = attachment.get("url")
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 2dcf710..a637025 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -14,6 +14,7 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import FeishuConfig
import importlib.util
@@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel):
(file_path, content_text) - file_path is None if download failed
"""
loop = asyncio.get_running_loop()
- media_dir = Path.home() / ".nanobot" / "media"
- media_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = get_media_dir("feishu")
data, filename = None, None
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 4967ac1..63cb0ca 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -38,7 +38,7 @@ except ImportError as e:
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
-from nanobot.config.loader import get_data_dir
+from nanobot.config.paths import get_data_dir, get_media_dir
from nanobot.utils.helpers import safe_filename
TYPING_NOTICE_TIMEOUT_MS = 30_000
@@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel):
return False
def _media_dir(self) -> Path:
- d = get_data_dir() / "media" / "matrix"
- d.mkdir(parents=True, exist_ok=True)
- return d
+ return get_media_dir("matrix")
@staticmethod
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py
index e762dfd..09e31c3 100644
--- a/nanobot/channels/mochat.py
+++ b/nanobot/channels/mochat.py
@@ -15,8 +15,8 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import MochatConfig
-from nanobot.utils.helpers import get_data_path
try:
import socketio
@@ -224,7 +224,7 @@ class MochatChannel(BaseChannel):
self._socket: Any = None
self._ws_connected = self._ws_ready = False
- self._state_dir = get_data_path() / "mochat"
+ self._state_dir = get_runtime_subdir("mochat")
self._cursor_path = self._state_dir / "session_cursors.json"
self._session_cursor: dict[str, int] = {}
self._cursor_save_task: asyncio.Task | None = None
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 501a3c1..ecb1440 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import TelegramConfig
from nanobot.utils.helpers import split_message
@@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel):
getattr(media_file, 'mime_type', None),
getattr(media_file, 'file_name', None),
)
- # Save to workspace/media/
- from pathlib import Path
- media_dir = Path.home() / ".nanobot" / "media"
- media_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = get_media_dir("telegram")
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
await file.download_to_drive(str(file_path))
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 47c9a30..da8906d 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -30,6 +30,7 @@ from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
+from nanobot.config.paths import get_workspace_path
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
@@ -99,7 +100,9 @@ def _init_prompt_session() -> None:
except Exception:
pass
- history_file = Path.home() / ".nanobot" / "history" / "cli_history"
+ from nanobot.config.paths import get_cli_history_path
+
+ history_file = get_cli_history_path()
history_file.parent.mkdir(parents=True, exist_ok=True)
_PROMPT_SESSION = PromptSession(
@@ -170,7 +173,6 @@ def onboard():
"""Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config
- from nanobot.utils.helpers import get_workspace_path
config_path = get_config_path()
@@ -271,8 +273,9 @@ def _make_provider(config: Config):
@app.command()
def gateway(
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
- config: str = typer.Option(None, "--config", "-c", help="Path to config file"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the nanobot gateway."""
# Set config path if provided (must be done before any imports that use get_data_dir)
@@ -288,7 +291,8 @@ def gateway(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
- from nanobot.config.loader import get_data_dir, load_config
+ from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -301,13 +305,15 @@ def gateway(
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
+ if workspace:
+ config.agents.defaults.workspace = workspace
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
- cron_store_path = get_data_dir() / "cron" / "jobs.json"
+ cron_store_path = get_cron_dir() / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
@@ -476,7 +482,8 @@ def agent(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
- from nanobot.config.loader import get_data_dir, load_config
+ from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
config = load_config()
@@ -486,7 +493,7 @@ def agent(
provider = _make_provider(config)
# Create cron service for tool usage (no callback needed for CLI unless running)
- cron_store_path = get_data_dir() / "cron" / "jobs.json"
+ cron_store_path = get_cron_dir() / "jobs.json"
cron = CronService(cron_store_path)
if logs:
@@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path:
import subprocess
# User's bridge location
- user_bridge = Path.home() / ".nanobot" / "bridge"
+ from nanobot.config.paths import get_bridge_install_dir
+
+ user_bridge = get_bridge_install_dir()
# Check if already built
if (user_bridge / "dist" / "index.js").exists():
@@ -810,6 +819,7 @@ def channels_login():
import subprocess
from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_runtime_subdir
config = load_config()
bridge_dir = _get_bridge_dir()
@@ -820,6 +830,7 @@ def channels_login():
env = {**os.environ}
if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
+ env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py
index 6c59668..e2c24f8 100644
--- a/nanobot/config/__init__.py
+++ b/nanobot/config/__init__.py
@@ -1,6 +1,30 @@
"""Configuration module for nanobot."""
from nanobot.config.loader import get_config_path, load_config
+from nanobot.config.paths import (
+ get_bridge_install_dir,
+ get_cli_history_path,
+ get_cron_dir,
+ get_data_dir,
+ get_legacy_sessions_dir,
+ get_logs_dir,
+ get_media_dir,
+ get_runtime_subdir,
+ get_workspace_path,
+)
from nanobot.config.schema import Config
-__all__ = ["Config", "load_config", "get_config_path"]
+__all__ = [
+ "Config",
+ "load_config",
+ "get_config_path",
+ "get_data_dir",
+ "get_runtime_subdir",
+ "get_media_dir",
+ "get_cron_dir",
+ "get_logs_dir",
+ "get_workspace_path",
+ "get_cli_history_path",
+ "get_bridge_install_dir",
+ "get_legacy_sessions_dir",
+]
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index 4355bd3..7d309e5 100644
--- a/nanobot/config/loader.py
+++ b/nanobot/config/loader.py
@@ -23,14 +23,6 @@ def get_config_path() -> Path:
return Path.home() / ".nanobot" / "config.json"
-def get_data_dir() -> Path:
- """Get the nanobot data directory (derived from config path)."""
- config_path = get_config_path()
- # If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/
- # If config is ~/.nanobot/config.json, data dir is ~/.nanobot/
- return config_path.parent
-
-
def load_config(config_path: Path | None = None) -> Config:
"""
Load configuration from file or create default.
diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py
new file mode 100644
index 0000000..f4dfbd9
--- /dev/null
+++ b/nanobot/config/paths.py
@@ -0,0 +1,55 @@
+"""Runtime path helpers derived from the active config context."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from nanobot.config.loader import get_config_path
+from nanobot.utils.helpers import ensure_dir
+
+
+def get_data_dir() -> Path:
+ """Return the instance-level runtime data directory."""
+ return ensure_dir(get_config_path().parent)
+
+
+def get_runtime_subdir(name: str) -> Path:
+ """Return a named runtime subdirectory under the instance data dir."""
+ return ensure_dir(get_data_dir() / name)
+
+
+def get_media_dir(channel: str | None = None) -> Path:
+ """Return the media directory, optionally namespaced per channel."""
+ base = get_runtime_subdir("media")
+ return ensure_dir(base / channel) if channel else base
+
+
+def get_cron_dir() -> Path:
+ """Return the cron storage directory."""
+ return get_runtime_subdir("cron")
+
+
+def get_logs_dir() -> Path:
+ """Return the logs directory."""
+ return get_runtime_subdir("logs")
+
+
+def get_workspace_path(workspace: str | None = None) -> Path:
+ """Resolve and ensure the agent workspace path."""
+ path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
+ return ensure_dir(path)
+
+
+def get_cli_history_path() -> Path:
+ """Return the shared CLI history file path."""
+ return Path.home() / ".nanobot" / "history" / "cli_history"
+
+
+def get_bridge_install_dir() -> Path:
+ """Return the shared WhatsApp bridge installation directory."""
+ return Path.home() / ".nanobot" / "bridge"
+
+
+def get_legacy_sessions_dir() -> Path:
+ """Return the legacy global session directory used for migration fallback."""
+ return Path.home() / ".nanobot" / "sessions"
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index dce4b2e..f0a6484 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -9,6 +9,7 @@ from typing import Any
from loguru import logger
+from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.utils.helpers import ensure_dir, safe_filename
@@ -79,7 +80,7 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
self.sessions_dir = ensure_dir(self.workspace / "sessions")
- self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
+ self.legacy_sessions_dir = get_legacy_sessions_dir()
self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path:
diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py
index 9163e38..46f02ac 100644
--- a/nanobot/utils/__init__.py
+++ b/nanobot/utils/__init__.py
@@ -1,5 +1,5 @@
"""Utility functions for nanobot."""
-from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path
+from nanobot.utils.helpers import ensure_dir
-__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"]
+__all__ = ["ensure_dir"]
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
index 6e8ecd5..57c60dc 100644
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> Path:
return path
-def get_data_path() -> Path:
- """Get nanobot data directory (derived from config path)."""
- from nanobot.config.loader import get_data_dir
- return ensure_dir(get_data_dir())
-
-
-def get_workspace_path(workspace: str | None = None) -> Path:
- """Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
- path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
- return ensure_dir(path)
-
-
def timestamp() -> str:
"""Current ISO timestamp."""
return datetime.now().isoformat()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 044d113..a276653 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model
runner = CliRunner()
+class _StopGateway(RuntimeError):
+ pass
+
+
@pytest.fixture
def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
patch("nanobot.config.loader.load_config") as mock_lc, \
- patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
+ patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data")
if base_dir.exists():
@@ -128,3 +132,94 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
+
+
+def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "config-workspace")
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr(
+ "nanobot.config.loader.set_config_path",
+ lambda path: seen.__setitem__("config_path", path),
+ )
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
+ monkeypatch.setattr(
+ "nanobot.cli.commands.sync_workspace_templates",
+ lambda path: seen.__setitem__("workspace", path),
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file)])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert seen["config_path"] == config_file.resolve()
+ assert seen["workspace"] == Path(config.agents.defaults.workspace)
+
+
+def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "config-workspace")
+ override = tmp_path / "override-workspace"
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
+ monkeypatch.setattr(
+ "nanobot.cli.commands.sync_workspace_templates",
+ lambda path: seen.__setitem__("workspace", path),
+ )
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(
+ app,
+ ["gateway", "--config", str(config_file), "--workspace", str(override)],
+ )
+
+ assert isinstance(result.exception, _StopGateway)
+ assert seen["workspace"] == override
+ assert config.workspace_path == override
+
+
+def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "config-workspace")
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
+ monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron")
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
+ monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
+ monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
+
+ class _StopCron:
+ def __init__(self, store_path: Path) -> None:
+ seen["cron_store"] = store_path
+ raise _StopGateway("stop")
+
+ monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file)])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py
new file mode 100644
index 0000000..473a6c8
--- /dev/null
+++ b/tests/test_config_paths.py
@@ -0,0 +1,42 @@
+from pathlib import Path
+
+from nanobot.config.paths import (
+ get_bridge_install_dir,
+ get_cli_history_path,
+ get_cron_dir,
+ get_data_dir,
+ get_legacy_sessions_dir,
+ get_logs_dir,
+ get_media_dir,
+ get_runtime_subdir,
+ get_workspace_path,
+)
+
+
+def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance-a" / "config.json"
+ monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
+
+ assert get_data_dir() == config_file.parent
+ assert get_runtime_subdir("cron") == config_file.parent / "cron"
+ assert get_cron_dir() == config_file.parent / "cron"
+ assert get_logs_dir() == config_file.parent / "logs"
+
+
+def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance-b" / "config.json"
+ monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
+
+ assert get_media_dir() == config_file.parent / "media"
+ assert get_media_dir("telegram") == config_file.parent / "media" / "telegram"
+
+
+def test_shared_and_legacy_paths_remain_global() -> None:
+ assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
+ assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
+ assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
+
+
+def test_workspace_path_is_explicitly_resolved() -> None:
+ assert get_workspace_path() == Path.home() / ".nanobot" / "workspace"
+ assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"
From 0a5daf3c86f2d5c78bdfd63409e8bf45058211b2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 03:03:25 +0000
Subject: [PATCH 105/120] docs: update readme for multiple instances and cli
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 5bd11f8..0bb6efe 100644
--- a/README.md
+++ b/README.md
@@ -903,7 +903,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
-## Multiple Instances
+## 🧩 Multiple Instances
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run.
@@ -992,7 +992,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
- `--workspace` overrides the workspace defined in the config file
- Cron jobs and runtime media/state are derived from the config directory
-## CLI Reference
+## 💻 CLI Reference
| Command | Description |
|---------|-------------|
From bf0ab93b06c395dec1b155ba46dd8e80352a19df Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 03:24:15 +0000
Subject: [PATCH 106/120] Merge branch 'main' into pr-1635
---
README.md | 13 ++++++++---
nanobot/cli/commands.py | 22 ++++++++---------
tests/test_commands.py | 52 ++++++++++++++++++++++++++++++++++++-----
3 files changed, 66 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index bc11cc8..13971e2 100644
--- a/README.md
+++ b/README.md
@@ -724,7 +724,10 @@ nanobot provider login openai-codex
nanobot agent -m "Hello!"
# Target a specific workspace/config locally
-nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!"
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
+
+# One-off workspace override on top of that config
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
@@ -930,11 +933,15 @@ When using `--config`, nanobot derives its runtime data directory from the confi
To open a CLI session against one of these instances locally:
```bash
-nanobot agent -w ~/.nanobot/botA -m "Hello from botA"
-nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
+nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
+
+# Optional one-off workspace override
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
```
> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
+
| Component | Resolved From | Example |
|-----------|---------------|---------|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index d03ef93..2c8d6d3 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -266,9 +266,17 @@ def _make_provider(config: Config):
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
"""Load config and optionally override the active workspace."""
- from nanobot.config.loader import load_config
+ from nanobot.config.loader import load_config, set_config_path
+
+ config_path = None
+ if config:
+ config_path = Path(config).expanduser().resolve()
+ if not config_path.exists():
+ console.print(f"[red]Error: Config file not found: {config_path}[/red]")
+ raise typer.Exit(1)
+ set_config_path(config_path)
+ console.print(f"[dim]Using config: {config_path}[/dim]")
- config_path = Path(config) if config else None
loaded = load_config(config_path)
if workspace:
loaded.agents.defaults.workspace = workspace
@@ -288,16 +296,6 @@ def gateway(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the nanobot gateway."""
- # Set config path if provided (must be done before any imports that use get_data_dir)
- if config:
- from nanobot.config.loader import set_config_path
- config_path = Path(config).expanduser().resolve()
- if not config_path.exists():
- console.print(f"[red]Error: Config file not found: {config_path}[/red]")
- raise typer.Exit(1)
- set_config_path(config_path)
- console.print(f"[dim]Using config: {config_path}[/dim]")
-
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
diff --git a/tests/test_commands.py b/tests/test_commands.py
index e3709da..19c1998 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -191,13 +191,52 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True)
-def test_agent_uses_explicit_config_path(mock_agent_runtime):
- config_path = Path("/tmp/agent-config.json")
+def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path):
+ config_path = tmp_path / "agent-config.json"
+ config_path.write_text("{}")
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
assert result.exit_code == 0
- assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
+
+
+def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ seen: dict[str, Path] = {}
+
+ monkeypatch.setattr(
+ "nanobot.config.loader.set_config_path",
+ lambda path: seen.__setitem__("config_path", path),
+ )
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron")
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
+ monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
+ monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
+
+ class _FakeAgentLoop:
+ def __init__(self, *args, **kwargs) -> None:
+ pass
+
+ async def process_direct(self, *_args, **_kwargs) -> str:
+ return "ok"
+
+ async def close_mcp(self) -> None:
+ return None
+
+ monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
+
+ result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
+
+ assert result.exit_code == 0
+ assert seen["config_path"] == config_file.resolve()
def test_agent_overrides_workspace_path(mock_agent_runtime):
@@ -211,8 +250,9 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
-def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime):
- config_path = Path("/tmp/agent-config.json")
+def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
+ config_path = tmp_path / "agent-config.json"
+ config_path.write_text("{}")
workspace_path = Path("/tmp/agent-workspace")
result = runner.invoke(
@@ -221,7 +261,7 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime)
)
assert result.exit_code == 0
- assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
From 1421ac501c381c253dfca156558b16d6a0f73a64 Mon Sep 17 00:00:00 2001
From: TheAutomatic
Date: Sun, 8 Mar 2026 07:04:06 -0700
Subject: [PATCH 107/120] feat(qq): send messages using markdown payload
---
nanobot/channels/qq.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 4809fd3..5ac06e3 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -113,16 +113,16 @@ class QQChannel(BaseChannel):
if msg_type == "group":
await self._client.api.post_group_message(
group_openid=msg.chat_id,
- msg_type=0,
- content=msg.content,
+ msg_type=2,
+ markdown={"content": msg.content},
msg_id=msg_id,
msg_seq=self._msg_seq,
)
else:
await self._client.api.post_c2c_message(
openid=msg.chat_id,
- msg_type=0,
- content=msg.content,
+ msg_type=2,
+ markdown={"content": msg.content},
msg_id=msg_id,
msg_seq=self._msg_seq,
)
From ed3b9c16f959d5820298673fe732d899dec9a593 Mon Sep 17 00:00:00 2001
From: Alfredo Arenas
Date: Sun, 8 Mar 2026 08:05:18 -0600
Subject: [PATCH 108/120] fix: handle CancelledError in MCP tool calls to
prevent process crash
MCP SDK's anyio cancel scopes can leak CancelledError on timeout or
failure paths. Since CancelledError is a BaseException (not Exception),
it escapes both MCPToolWrapper.execute() and ToolRegistry.execute(),
crashing the agent loop.
Now catches CancelledError and returns a graceful error to the LLM,
while still re-raising genuine task cancellations from /stop.
Also catches general Exception for other MCP failures (connection
drops, invalid responses, etc.).
Related: #1055
---
nanobot/agent/tools/mcp.py | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 2cbffd0..cf0a842 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -34,7 +34,7 @@ class MCPToolWrapper(Tool):
def parameters(self) -> dict[str, Any]:
return self._parameters
- async def execute(self, **kwargs: Any) -> str:
+async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
@@ -44,13 +44,24 @@ class MCPToolWrapper(Tool):
except asyncio.TimeoutError:
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
+ except asyncio.CancelledError:
+ # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
+ # Re-raise only if our task was externally cancelled (e.g. /stop).
+ task = asyncio.current_task()
+ if task is not None and task.cancelling() > 0:
+ raise
+ logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
+ return f"(MCP tool call was cancelled)"
+ except Exception as exc:
+ logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc)
+ return f"(MCP tool call failed: {type(exc).__name__})"
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
- return "\n".join(parts) or "(no output)"
+ return "\n".join(parts) or "(no output)
async def connect_mcp_servers(
From 7cbb254a8e5140d8393d608a2f41c2885b080ce7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 15:39:40 +0000
Subject: [PATCH 109/120] fix: remove stale IDENTITY bootstrap entry
---
nanobot/agent/context.py | 2 +-
tests/test_context_prompt_cache.py | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 27511fa..820baf5 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -16,7 +16,7 @@ from nanobot.utils.helpers import detect_image_mime
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
- BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
+ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
def __init__(self, workspace: Path):
diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py
index ce796e2..6eb4b4f 100644
--- a/tests/test_context_prompt_cache.py
+++ b/tests/test_context_prompt_cache.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import datetime as real_datetime
+from importlib.resources import files as pkg_files
from pathlib import Path
import datetime as datetime_module
@@ -23,6 +24,13 @@ def _make_workspace(tmp_path: Path) -> Path:
return workspace
+def test_bootstrap_files_are_backed_by_templates() -> None:
+ template_dir = pkg_files("nanobot") / "templates"
+
+ for filename in ContextBuilder.BOOTSTRAP_FILES:
+ assert (template_dir / filename).is_file(), f"missing bootstrap template: {filename}"
+
+
def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None:
"""System prompt should not change just because wall clock minute changes."""
monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime)
From 5eb67facff3b1e063302e5386072f02ca9a528c2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:01:06 +0000
Subject: [PATCH 110/120] Merge branch 'main' into pr-1728 and harden MCP tool
cancellation handling
---
nanobot/agent/tools/mcp.py | 15 ++++--
tests/test_mcp_tool.py | 99 ++++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 4 deletions(-)
create mode 100644 tests/test_mcp_tool.py
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index cf0a842..400979b 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -34,8 +34,9 @@ class MCPToolWrapper(Tool):
def parameters(self) -> dict[str, Any]:
return self._parameters
-async def execute(self, **kwargs: Any) -> str:
+ async def execute(self, **kwargs: Any) -> str:
from mcp import types
+
try:
result = await asyncio.wait_for(
self._session.call_tool(self._original_name, arguments=kwargs),
@@ -51,17 +52,23 @@ async def execute(self, **kwargs: Any) -> str:
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
- return f"(MCP tool call was cancelled)"
+ return "(MCP tool call was cancelled)"
except Exception as exc:
- logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc)
+ logger.exception(
+ "MCP tool '{}' failed: {}: {}",
+ self._name,
+ type(exc).__name__,
+ exc,
+ )
return f"(MCP tool call failed: {type(exc).__name__})"
+
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
- return "\n".join(parts) or "(no output)
+ return "\n".join(parts) or "(no output)"
async def connect_mcp_servers(
diff --git a/tests/test_mcp_tool.py b/tests/test_mcp_tool.py
new file mode 100644
index 0000000..bf68425
--- /dev/null
+++ b/tests/test_mcp_tool.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import asyncio
+import sys
+from types import ModuleType, SimpleNamespace
+
+import pytest
+
+from nanobot.agent.tools.mcp import MCPToolWrapper
+
+
+class _FakeTextContent:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+
+@pytest.fixture(autouse=True)
+def _fake_mcp_module(monkeypatch: pytest.MonkeyPatch) -> None:
+ mod = ModuleType("mcp")
+ mod.types = SimpleNamespace(TextContent=_FakeTextContent)
+ monkeypatch.setitem(sys.modules, "mcp", mod)
+
+
+def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
+ tool_def = SimpleNamespace(
+ name="demo",
+ description="demo tool",
+ inputSchema={"type": "object", "properties": {}},
+ )
+ return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
+
+
+@pytest.mark.asyncio
+async def test_execute_returns_text_blocks() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ assert arguments == {"value": 1}
+ return SimpleNamespace(content=[_FakeTextContent("hello"), 42])
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
+
+ result = await wrapper.execute(value=1)
+
+ assert result == "hello\n42"
+
+
+@pytest.mark.asyncio
+async def test_execute_returns_timeout_message() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ await asyncio.sleep(1)
+ return SimpleNamespace(content=[])
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=0.01)
+
+ result = await wrapper.execute()
+
+ assert result == "(MCP tool call timed out after 0.01s)"
+
+
+@pytest.mark.asyncio
+async def test_execute_handles_server_cancelled_error() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ raise asyncio.CancelledError()
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
+
+ result = await wrapper.execute()
+
+ assert result == "(MCP tool call was cancelled)"
+
+
+@pytest.mark.asyncio
+async def test_execute_re_raises_external_cancellation() -> None:
+ started = asyncio.Event()
+
+ async def call_tool(_name: str, arguments: dict) -> object:
+ started.set()
+ await asyncio.sleep(60)
+ return SimpleNamespace(content=[])
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10)
+ task = asyncio.create_task(wrapper.execute())
+ await started.wait()
+
+ task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+
+@pytest.mark.asyncio
+async def test_execute_handles_generic_exception() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ raise RuntimeError("boom")
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
+
+ result = await wrapper.execute()
+
+ assert result == "(MCP tool call failed: RuntimeError)"
From 4715321319f7282ffe9df99be59898a9782a2440 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:39:37 +0000
Subject: [PATCH 111/120] Merge branch 'main' into pr-1579 and tighten platform
guidance
---
nanobot/agent/context.py | 25 ++++++-------------------
nanobot/skills/memory/SKILL.md | 13 +++++++------
2 files changed, 13 insertions(+), 25 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 3dced80..2c648eb 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -62,27 +62,14 @@ Skills with available="false" need dependencies installed first - you can try in
platform_policy = ""
if system == "Windows":
platform_policy = """## Platform Policy (Windows)
-- You are running on Windows. Shell commands executed via the `exec` tool run under the default Windows shell (PowerShell or cmd.exe) unless you explicitly invoke another shell.
-- Prefer UTF-8 for file I/O and command output. If terminal output is garbled/mojibake, retry with:
- - PowerShell: `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; `
- - cmd.exe: `chcp 65001 >NUL & `
-- Do NOT assume GNU tools like `grep`, `sed`, `awk` exist. Prefer Windows built-ins:
- - Search text: `findstr /i "keyword" path\\to\\file`
- - List files: `dir`
- - Show file: `type path\\to\\file`
-- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reliability.
-"""
- elif system == "Darwin":
- platform_policy = """## Platform Policy (macOS)
-- You are running on macOS. Prefer POSIX tools and UTF-8.
-- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations.
-- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility.
+- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist.
+- Prefer Windows-native commands or file tools when they are more reliable.
+- If terminal output is garbled, retry with UTF-8 output enabled.
"""
else:
- platform_policy = """## Platform Policy (Linux)
-- You are running on Linux. Prefer POSIX tools and UTF-8.
-- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations.
-- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility.
+ platform_policy = """## Platform Policy (POSIX)
+- You are running on a POSIX system. Prefer UTF-8 and standard shell tools.
+- Use file tools when they are simpler or more reliable than shell commands.
"""
return f"""# nanobot 🐈
diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md
index 865f11f..3f0a8fc 100644
--- a/nanobot/skills/memory/SKILL.md
+++ b/nanobot/skills/memory/SKILL.md
@@ -13,16 +13,17 @@ always: true
## Search Past Events
-**Recommended approach (cross-platform):**
-- Use `read_file` to read `memory/HISTORY.md`, then search in-memory
-- This is the most reliable and portable method on all platforms
+Choose the search method based on file size:
-**Alternative (if you need command-line search):**
+- Small `memory/HISTORY.md`: use `read_file`, then search in-memory
+- Large or long-lived `memory/HISTORY.md`: use the `exec` tool for targeted search
+
+Examples:
- **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md`
- **Windows:** `findstr /i "keyword" memory\HISTORY.md`
-- **Python (cross-platform):** `python -c "import re; content=open('memory/HISTORY.md', encoding='utf-8').read(); print('\n'.join([l for l in content.split('\n') if 'keyword' in l.lower()][-20:]))"`
+- **Cross-platform Python:** `python -c "from pathlib import Path; text = Path('memory/HISTORY.md').read_text(encoding='utf-8'); print('\n'.join([l for l in text.splitlines() if 'keyword' in l.lower()][-20:]))"`
-Use the `exec` tool to run these commands. For complex searches, prefer `read_file` + in-memory filtering.
+Prefer targeted command-line search for large history files.
## When to Update MEMORY.md
From a0bb4320f48bd4f25e9daf98de7ad2eb9276a42e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:44:47 +0000
Subject: [PATCH 112/120] chore: bump version to 0.1.4.post4
---
nanobot/__init__.py | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/__init__.py b/nanobot/__init__.py
index 4dba5f4..d331109 100644
--- a/nanobot/__init__.py
+++ b/nanobot/__init__.py
@@ -2,5 +2,5 @@
nanobot - A lightweight AI agent framework
"""
-__version__ = "0.1.4.post3"
+__version__ = "0.1.4.post4"
__logo__ = "🐈"
diff --git a/pyproject.toml b/pyproject.toml
index 41d0fbb..62cf616 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.4.post3"
+version = "0.1.4.post4"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
From 998021f571a140574af0c29a3c36f51b7ff71e79 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:57:28 +0000
Subject: [PATCH 113/120] docs: refresh install/update guidance and bump
v0.1.4.post4
---
README.md | 31 +++++++++++++++++++++++++++----
SECURITY.md | 4 ++--
2 files changed, 29 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 13971e2..d3401ea 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,29 @@ uv tool install nanobot-ai
pip install nanobot-ai
```
+### Update to latest version
+
+**PyPI / pip**
+
+```bash
+pip install -U nanobot-ai
+nanobot --version
+```
+
+**uv**
+
+```bash
+uv tool upgrade nanobot-ai
+nanobot --version
+```
+
+**Using WhatsApp?** Rebuild the local bridge after upgrading:
+
+```bash
+rm -rf ~/.nanobot/bridge
+nanobot channels login
+```
+
## 🚀 Quick Start
> [!TIP]
@@ -374,7 +397,7 @@ pip install nanobot-ai[matrix]
| Option | Description |
|--------|-------------|
-| `allowFrom` | User IDs allowed to interact. Empty = all senders. |
+| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. |
| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
@@ -428,7 +451,7 @@ nanobot gateway
```
> WhatsApp bridge updates are not applied automatically for existing installations.
-> If you upgrade nanobot and need the latest WhatsApp bridge, run:
+> After upgrading nanobot, rebuild the local bridge with:
> `rm -rf ~/.nanobot/bridge && nanobot channels login`
@@ -900,13 +923,13 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
-> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`.
+> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
-| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
+| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
## 🧩 Multiple Instances
diff --git a/SECURITY.md b/SECURITY.md
index af4da71..d98adb6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json
```
**Security Notes:**
-- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** — set `["*"]` to explicitly allow everyone.
+- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp
- Review access logs regularly for unauthorized access attempts
@@ -212,7 +212,7 @@ If you suspect a security breach:
- Input length limits on HTTP requests
✅ **Authentication**
-- Allow-list based access control — in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all)
+- Allow-list based access control — in `v0.1.4.post3` and earlier empty `allowFrom` allowed all; since `v0.1.4.post4` it denies all (`["*"]` explicitly allows all)
- Failed authentication attempt logging
✅ **Resource Protection**
From 4147d0ff9d12f9faaa3aefe5be449b18461588d1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 17:00:09 +0000
Subject: [PATCH 114/120] docs: update v0.1.4.post4 release news
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index d3401ea..2450b8c 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
From f19cefb1b9b61dcf902afb5666aea80b1c362e46 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 17:00:46 +0000
Subject: [PATCH 115/120] docs: update v0.1.4.post4 release news
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2450b8c..f169bd7 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
+- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
From 4044b85d4bfa9104b633f3cb408894f0459a0164 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Mon, 9 Mar 2026 01:32:10 +0800
Subject: [PATCH 116/120] fix: ensure feishu audio file has .opus extension for
Groq Whisper compatibility
---
nanobot/channels/feishu.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index a637025..0409c32 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -753,8 +753,9 @@ class FeishuChannel(BaseChannel):
None, self._download_file_sync, message_id, file_key, msg_type
)
if not filename:
- ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "")
- filename = f"{file_key[:16]}{ext}"
+ filename = file_key[:16]
+ if msg_type == "audio" and not filename.endswith(".opus"):
+ filename = f"{filename}.opus"
if data and filename:
file_path = media_dir / filename
From 620d7896c710748053257695d25c3391aa637dc5 Mon Sep 17 00:00:00 2001
From: ailuntz
Date: Tue, 10 Mar 2026 00:14:34 +0800
Subject: [PATCH 117/120] fix(slack): define thread usage when sending messages
---
nanobot/channels/slack.py | 2 +-
tests/test_slack_channel.py | 88 +++++++++++++++++++++++++++++++++++++
2 files changed, 89 insertions(+), 1 deletion(-)
create mode 100644 tests/test_slack_channel.py
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index a4e7324..e36c4c9 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -82,6 +82,7 @@ class SlackChannel(BaseChannel):
thread_ts = slack_meta.get("thread_ts")
channel_type = slack_meta.get("channel_type")
# Only reply in thread for channel/group messages; DMs don't use threads
+ use_thread = bool(thread_ts and channel_type != "im")
thread_ts_param = thread_ts if use_thread else None
# Slack rejects empty text payloads. Keep media-only messages media-only,
@@ -278,4 +279,3 @@ class SlackChannel(BaseChannel):
if parts:
rows.append(" · ".join(parts))
return "\n".join(rows)
-
diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py
new file mode 100644
index 0000000..18b96ef
--- /dev/null
+++ b/tests/test_slack_channel.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import pytest
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.slack import SlackChannel
+from nanobot.config.schema import SlackConfig
+
+
+class _FakeAsyncWebClient:
+ def __init__(self) -> None:
+ self.chat_post_calls: list[dict[str, object | None]] = []
+ self.file_upload_calls: list[dict[str, object | None]] = []
+
+ async def chat_postMessage(
+ self,
+ *,
+ channel: str,
+ text: str,
+ thread_ts: str | None = None,
+ ) -> None:
+ self.chat_post_calls.append(
+ {
+ "channel": channel,
+ "text": text,
+ "thread_ts": thread_ts,
+ }
+ )
+
+ async def files_upload_v2(
+ self,
+ *,
+ channel: str,
+ file: str,
+ thread_ts: str | None = None,
+ ) -> None:
+ self.file_upload_calls.append(
+ {
+ "channel": channel,
+ "file": file,
+ "thread_ts": thread_ts,
+ }
+ )
+
+
+@pytest.mark.asyncio
+async def test_send_uses_thread_for_channel_messages() -> None:
+ channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
+ fake_web = _FakeAsyncWebClient()
+ channel._web_client = fake_web
+
+ await channel.send(
+ OutboundMessage(
+ channel="slack",
+ chat_id="C123",
+ content="hello",
+ media=["/tmp/demo.txt"],
+ metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "channel"}},
+ )
+ )
+
+ assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
+ assert len(fake_web.file_upload_calls) == 1
+ assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
+
+
+@pytest.mark.asyncio
+async def test_send_omits_thread_for_dm_messages() -> None:
+ channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
+ fake_web = _FakeAsyncWebClient()
+ channel._web_client = fake_web
+
+ await channel.send(
+ OutboundMessage(
+ channel="slack",
+ chat_id="D123",
+ content="hello",
+ media=["/tmp/demo.txt"],
+ metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "im"}},
+ )
+ )
+
+ assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["thread_ts"] is None
+ assert len(fake_web.file_upload_calls) == 1
+ assert fake_web.file_upload_calls[0]["thread_ts"] is None
From 9c88e40a616190aca65ce3d3149f4529865ca5d8 Mon Sep 17 00:00:00 2001
From: ailuntz
Date: Tue, 10 Mar 2026 00:32:42 +0800
Subject: [PATCH 118/120] fix(cli): respect gateway port from config when
--port omitted
---
nanobot/cli/commands.py | 5 +++--
tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2c8d6d3..a5906d2 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
@app.command()
def gateway(
- port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
@@ -310,8 +310,9 @@ def gateway(
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
+ selected_port = port if port is not None else config.gateway.port
- console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
+ console.print(f"{__logo__} Starting nanobot gateway on port {selected_port}...")
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 19c1998..9479dad 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -328,6 +328,50 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path)
assert config.workspace_path == override
+def test_gateway_uses_port_from_config_when_cli_port_is_omitted(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file)])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert "Starting nanobot gateway on port 18791" in result.stdout
+
+
+def test_gateway_cli_port_overrides_config_port(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18801"])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert "Starting nanobot gateway on port 18801" in result.stdout
+
+
def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance" / "config.json"
config_file.parent.mkdir(parents=True)
From 28330940d0b2cefbfe740957ee8f51ed9349c24e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Mar 2026 17:18:10 +0000
Subject: [PATCH 119/120] fix(slack): skip thread_ts for direct messages
---
nanobot/channels/slack.py | 5 ++---
tests/test_slack_channel.py | 2 ++
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index e36c4c9..0384d8d 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -81,9 +81,8 @@ class SlackChannel(BaseChannel):
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
thread_ts = slack_meta.get("thread_ts")
channel_type = slack_meta.get("channel_type")
- # Only reply in thread for channel/group messages; DMs don't use threads
- use_thread = bool(thread_ts and channel_type != "im")
- thread_ts_param = thread_ts if use_thread else None
+ # Slack DMs don't use threads; channel/group replies may keep thread_ts.
+ thread_ts_param = thread_ts if thread_ts and channel_type != "im" else None
# Slack rejects empty text payloads. Keep media-only messages media-only,
# but send a single blank message when the bot has no text or files to send.
diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py
index 18b96ef..891f86a 100644
--- a/tests/test_slack_channel.py
+++ b/tests/test_slack_channel.py
@@ -61,6 +61,7 @@ async def test_send_uses_thread_for_channel_messages() -> None:
)
assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["text"] == "hello\n"
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
@@ -83,6 +84,7 @@ async def test_send_omits_thread_for_dm_messages() -> None:
)
assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["text"] == "hello\n"
assert fake_web.chat_post_calls[0]["thread_ts"] is None
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] is None
From 1284c7217ea2c59a5a9e2786c5f550e9fb5ace1b Mon Sep 17 00:00:00 2001
From: Protocol Zero <257158451+Protocol-zero-0@users.noreply.github.com>
Date: Mon, 9 Mar 2026 20:12:11 +0000
Subject: [PATCH 120/120] fix(cli): let gateway use config port by default
Respect config.gateway.port when --port is omitted, while keeping CLI flags as the highest-precedence override.
---
nanobot/cli/commands.py | 3 ++-
tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 46 insertions(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2c8d6d3..37f08b2 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
@app.command()
def gateway(
- port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
@@ -310,6 +310,7 @@ def gateway(
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
+ port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
sync_workspace_templates(config.workspace_path)
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 19c1998..5d38942 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -356,3 +356,47 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat
assert isinstance(result.exception, _StopGateway)
assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
+
+
+def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file)])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert "port 18791" in result.stdout
+
+
+def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ config.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert "port 18792" in result.stdout