Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drivers/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ async def _on_message(self, message: discord.Message):
mentions=mentions,
source_proxy=self._media_proxy,
username=message.author.name,
is_dm=server_id == "",
)
await self.bridge.on_message(msg)

Expand Down
1 change: 1 addition & 0 deletions drivers/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ def _on_message_event(self, data) -> None:
message_id=msg.message_id,
reply_parent=msg.parent_id,
mentions=mentions,
is_dm=event.chat_type == "p2p_chat",
)

if self._loop:
Expand Down
145 changes: 119 additions & 26 deletions drivers/qq.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,57 @@

if data.get("message_type") == "group":
await self._on_group_message(data)
elif data.get("message_type") == "private":
await self._on_private_message(data)

async def _on_private_message(self, event: dict):
if event.get("user_id") == event.get("self_id"):
return

user_id = str(event.get("user_id", ""))
sender = event.get("sender", {})
nickname = sender.get("nickname") or user_id
self.logger.debug(
f"NapCat [{self.instance_id}] private message from {nickname}({user_id})"
)

time = event.get("time")

face_as_emoji: bool = self.config.cqface_mode == "emoji"
text, attachments, reply_id, mentions = await self._parse_message(
event, face_as_emoji=face_as_emoji
)

if not text.strip() and not attachments:
self.logger.debug(
f"NapCat [{self.instance_id}] ignoring empty private message from {nickname}({user_id})"
)
return

avatar_url = f"https://q.qlogo.cn/headimg_dl?dst_uin={user_id}&spec=640"
self_id = str(event.get("self_id", ""))
source_mentioned_self = any(str(m.get("id", "")) == self_id for m in mentions)

msg = NormalizedMessage(
platform=self.platform_name,
instance_id=self.instance_id,
channel={"user_id": user_id},
nickname=nickname,
user_id=user_id,
user_avatar=avatar_url,
text=text,
attachments=attachments,
message_id=str(event.get("message_id", "")),
reply_parent=reply_id,
mentions=mentions,
source_self_id=self_id,
source_mentioned_self=source_mentioned_self,
time=datetime.datetime.fromtimestamp(time).isoformat() if time else None,
source_proxy=self._media_proxy,

Check warning on line 562 in drivers/qq.py

View check run for this annotation

SiiWay Code Review / Claude Review

drivers/qq.py#L562

bug_risk: `datetime.datetime.fromtimestamp(time)` uses local time without a tz. Elsewhere in this codebase ISO strings are typically expected to be timezone-aware/UTC; emitting a naive local-time ISO string for DMs will be inconsistent with group messages and any downstream consumer that parses these. It also silently breaks if `time` is a string from some OneBot impls.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning (bug_risk): datetime.datetime.fromtimestamp(time) uses local time without a tz. Elsewhere in this codebase ISO strings are typically expected to be timezone-aware/UTC; emitting a naive local-time ISO string for DMs will be inconsistent with group messages and any downstream consumer that parses these. It also silently breaks if time is a string from some OneBot impls.

ts = event.get("time")
time_iso = None
if isinstance(ts, (int, float)):
    time_iso = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).isoformat()
# ...
    time=time_iso,

username="",
is_dm=True,
)
await self.bridge.on_message(msg)

async def _on_group_message(self, event: dict):
# NapCat echoes the bot's own sent messages back as real events;
Expand Down Expand Up @@ -818,11 +869,14 @@

return _DEFAULT_FORWARD_CQFACE_GIF_HOST

async def _upload_group_file_from_bytes(
async def _upload_file_from_bytes(
self,
data_bytes: bytes,
filename: str,
group_id: str,
target_id: str,
*,
upload_api: str = "upload_group_file",
id_key: str = "group_id",
) -> bool:
with tempfile.NamedTemporaryFile(
prefix="nextbridge-qq-",
Expand All @@ -834,17 +888,17 @@

try:
resp = await self._call(
"upload_group_file",
upload_api,
{
"group_id": int(group_id),
id_key: int(target_id),
"file": tmp_path,
"name": filename,
},
)
if resp and resp.get("status") == "ok":
return True
self.logger.warning(
f"QQ [{self.instance_id}] upload_group_file failed for '{filename}': {resp}"
f"QQ [{self.instance_id}] {upload_api} failed for '{filename}': {resp}"
)
return False
finally:
Expand Down Expand Up @@ -1947,6 +2001,21 @@
return str(data["message_id"])
return None

async def _api_send_private_msg(
self, user_id, message, *, timeout: float = 30.0
) -> str | None:
"""Send a private message via OneBot. Returns ``message_id`` on success or ``None``."""
resp = await self._call(
"send_private_msg",
{"user_id": int(user_id), "message": message},
timeout=timeout,
)
if resp and resp.get("status") == "ok":
data = resp.get("data") or {}
if "message_id" in data:
return str(data["message_id"])
return None

async def _api_get_group_member_info(
self, group_id, user_id, *, no_cache: bool = False
) -> dict | None:
Expand Down Expand Up @@ -2109,9 +2178,10 @@
**kwargs,
):
group_id = channel.get("group_id")
if not group_id:
user_id = channel.get("user_id")
if not group_id and not user_id:
self.logger.warning(
f"NapCat [{self.instance_id}] send: no group_id in channel {channel}"
f"NapCat [{self.instance_id}] send: no group_id or user_id in channel {channel}"
)
return None

Expand All @@ -2121,6 +2191,14 @@
)
return None

is_group = bool(group_id)

Check notice on line 2194 in drivers/qq.py

View check run for this annotation

SiiWay Code Review / Claude Review

drivers/qq.py#L2194

maintainability: `assert group_id or user_id` is a runtime no-op under `python -O` and duplicates the check just above. The `assert` lines sprinkled inside `_do_upload` likewise exist only to appease the type checker. Prefer narrowing via explicit branches or local variables so logic stays correct even with assertions disabled.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

info (maintainability): assert group_id or user_id is a runtime no-op under python -O and duplicates the check just above. The assert lines sprinkled inside _do_upload likewise exist only to appease the type checker. Prefer narrowing via explicit branches or local variables so logic stays correct even with assertions disabled.

is_group = bool(group_id)
if is_group:
    assert group_id is not None  # for type checker only
    target_id: str = str(group_id)
else:
    assert user_id is not None
    target_id = str(user_id)

async def _send_msg(segments):
    if is_group:
        return await self._api_send_group_msg(target_id, segments)
    return await self._api_send_private_msg(target_id, segments)

Then pass target_id into _do_upload instead of re-deriving from group_id/user_id inside the closure.

assert group_id or user_id

async def _send_msg(segments):
if is_group:
return await self._api_send_group_msg(group_id, segments)
return await self._api_send_private_msg(user_id, segments)

segments: list[dict] = []
msg_ids: list[str] = []
deferred_file_uploads = []
Expand Down Expand Up @@ -2151,9 +2229,7 @@
else:
t, c = rich_header.get("title", ""), rich_header.get("content", "")
prefix = f"[{t}" + (f" · {c}" if c else "") + "]"
msg_id = await self._api_send_group_msg(
group_id, [{"type": "text", "data": {"text": prefix}}]
)
msg_id = await _send_msg([{"type": "text", "data": {"text": prefix}}])
if not msg_id:
self.logger.warning(
f"NapCat [{self.instance_id}] failed to send standalone rich header "
Expand Down Expand Up @@ -2185,8 +2261,11 @@
segments.append(
{"type": "text", "data": {"text": text[last_idx:idx]}}
)
# Add mention segment
segments.append({"type": "at", "data": {"qq": m["id"]}})
# Add mention segment (converted to text in private chats)
if is_group:
segments.append({"type": "at", "data": {"qq": m["id"]}})
else:
segments.append({"type": "text", "data": {"text": mention_str}})
last_idx = idx + len(mention_str)

# Add remaining text
Expand Down Expand Up @@ -2278,15 +2357,26 @@
data_bytes, _ = result
fname = att.name or "file"

async def _do_upload(d=data_bytes, fn=fname, gid=group_id):
async def _do_upload(d=data_bytes, fn=fname, is_grp=is_group):
if is_grp:
assert group_id is not None
upload_api = "upload_group_file"
id_key = "group_id"
id_val = int(group_id)
else:
assert user_id is not None
upload_api = "upload_private_file"
id_key = "user_id"
id_val = int(user_id)

if self._supports_stream_file_upload():
mode = self._resolve_send_mode(len(d))
if mode == "base64":
b64 = base64.b64encode(d).decode()
await self._call(
"upload_group_file",
upload_api,
{
"group_id": int(gid),
id_key: id_val,
"file": f"base64://{b64}",
"name": fn,
},
Expand All @@ -2295,16 +2385,15 @@
file_path = await self._upload_file_stream(d, fn)
if file_path:
await self._call(
"upload_group_file",
upload_api,
{
"group_id": int(gid),
id_key: id_val,
"file": file_path,
"name": fn,
},
)
else:
await self._api_send_group_msg(
gid,
await _send_msg(
[
{
"type": "text",
Expand All @@ -2315,13 +2404,14 @@
],
)
else:
if not await self._upload_group_file_from_bytes(
if not await self._upload_file_from_bytes(
d,
fn,
str(gid),
str(id_val),
upload_api=upload_api,
id_key=id_key,
):
await self._api_send_group_msg(
gid,
await _send_msg(
[
{
"type": "text",
Expand Down Expand Up @@ -2356,16 +2446,19 @@
and standalone_segments
):
# If only reply segment remains, attach it to the first standalone segment
standalone_segments[0] = [main_segments[0], standalone_segments[0]] # ty: ignore[invalid-assignment]
standalone_segments[0] = [
main_segments[0],
standalone_segments[0],
] # ty: ignore[invalid-assignment]
main_segments = []
else:
msg_id = await self._api_send_group_msg(group_id, main_segments)
msg_id = await _send_msg(main_segments)
if msg_id:
msg_ids.append(msg_id)

for seg in standalone_segments:
msg_to_send = seg if isinstance(seg, list) else [seg]
msg_id = await self._api_send_group_msg(group_id, msg_to_send)
msg_id = await _send_msg(msg_to_send)
if msg_id:
msg_ids.append(msg_id)

Expand Down
1 change: 1 addition & 0 deletions drivers/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
time=msg.date.isoformat() if msg.date else None,
source_proxy=self._media_proxy,
username=username,
is_dm=msg.chat_id > 0,
)
await self.bridge.on_message(normalized)

Expand Down
1 change: 1 addition & 0 deletions drivers/yunhu.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ async def _on_message(self, event: dict):
message_id=str(mid) if mid else None,
reply_parent=str(pid) if pid else None,
source_proxy=self._media_proxy,
is_dm=chat_type == "user",
)
await self.bridge.on_message(msg)

Expand Down
17 changes: 13 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,6 @@ async def main():

bridge.load_sensitive_values(raw)

enabled_platforms = [key for key in raw if key != "global"]

# Load global configuration
global_config = raw.get("global", {})
bridge.strict_echo_match = global_config.get("strict_echo_match", False)
Expand Down Expand Up @@ -232,9 +230,20 @@ async def main():
bridge.set_middleware(middleware)
bridge.set_event_bus(event_bus)

# Discover and import driver modules (built-in, entrypoints, local)
# Discover and import driver modules (built-in, entrypoints, local, external)
plugin_cfg = validated_global.plugins
load_all_drivers(enabled_platforms, plugin_cfg.paths or None)
drivers_cfg = plugin_cfg.drivers

if drivers_cfg.enabled:
enabled_platforms = list(drivers_cfg.enabled)
else:
enabled_platforms = [key for key in raw if key != "global"]

for ext_name in drivers_cfg.external:
if ext_name not in enabled_platforms:
enabled_platforms.append(ext_name)

load_all_drivers(enabled_platforms, drivers_cfg, plugin_cfg.paths or None)
from drivers.registry import all_drivers

logger.info("NextBridge starting...")
Expand Down
14 changes: 7 additions & 7 deletions services/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,14 +355,14 @@ async def on_message(self, msg: NormalizedMessage):
# Handle internal commands
command = self._parse_internal_command(msg.text)
if command is not None:
if not self._is_allowed_command_source(msg):
logger.debug(
f"Ignored command from non-configured channel: "
f"instance={msg.instance_id} channel={msg.channel}"
)
return

action, args = command
if not self._is_allowed_command_source(msg):
if action != "bind" or not msg.is_dm:
logger.debug(
f"Ignored command from non-configured channel: "
f"instance={msg.instance_id} channel={msg.channel}"
)
return
sender_info = self._senders.get(msg.instance_id)
if action in ("", "help"):
if sender_info:
Expand Down
Loading
Loading