Skip to content

Conversation

PaloMiku
Copy link
Contributor

@PaloMiku PaloMiku commented Oct 8, 2025

Motivation / 动机

本次更新对 Misskey 适配器进行了大量功能更新和修复问题。

Modifications / 改动点

  • 参考 Misskey API 接口规范重构部分代码
  • 现在可以看懂 Misskey 的“投票”帖子内容
  • 增加可开关的文件上传功能
  • 预留更多可选字段接口为后续和插件做准备

Verification Steps / 验证步骤

  • Clone 代码并启动测试

Screenshots or Test Results / 运行截图或测试结果

image

Compatibility & Breaking Changes / 兼容性与破坏性变更

  • 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
  • 这不是一个破坏性变更。/ This is NOT a breaking change.

Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Sourcery 总结

更新 Misskey 适配器以支持带有重试和回退机制的并发文件上传,丰富笔记创建功能,增加 cw、投票、转推和频道字段,改进 WebSocket 处理和重试逻辑,并扩展 API 方法以支持灵活的负载选项。

新功能:

  • 添加文件上传支持,具有可配置的并发和文件夹设置,包括自动重试和回退 URL
  • 启用投票支持,通过提取、格式化并将投票数据嵌入消息中

改进:

  • 增强 WebSocket 客户端,以自动重新订阅所需频道,处理普通和带频道前缀的事件类型,并在 DEBUG 级别改进日志记录,提供简洁摘要和完整负载
  • 对重新连接延迟应用抖动,并使用指数退避和抖动增强 retry_async 装饰器

文档:

  • 在默认配置中公开新的 Misskey 文件上传设置 (enable_file_upload, upload_concurrency, upload_folder)
Original summary in English

Summary by Sourcery

Update Misskey adapter to support concurrent file uploads with retry and fallback mechanisms, enrich note creation with cw, poll, renote, and channel fields, improve WebSocket handling and retry logic, and extend API methods with flexible payload options.

New Features:

  • Add file upload support with configurable concurrency and folder settings, including automatic retries and fallback URLs
  • Enable poll support by extracting, formatting, and embedding poll data in messages

Enhancements:

  • Enhance WebSocket client to auto-resubscribe desired channels, handle both plain and channel-prefixed event types, and improve logging with concise summaries and full payloads at DEBUG level
  • Apply jitter to reconnect delays and augment retry_async decorator with exponential backoff and jitter

Documentation:

  • Expose new Misskey file upload settings (enable_file_upload, upload_concurrency, upload_folder) in default configuration

Sourcery 总结

更新 Misskey 适配器,增加了大量功能、增强和修复:实现了强大的文件上传工作流,丰富了笔记创建字段,改进了 WebSocket 处理和重试逻辑,并暴露了新的配置选项。

新功能:

  • 支持并发文件上传,具有可配置的并发数、重试、回退和从 URL 上传的端点
  • 启用 Misskey 投票的解析和嵌入,并添加 cw、renote、channel 和 reaction_acceptance 字段,以实现丰富的笔记创建
  • 引入统一的 send_message_with_media 并增强 send_by_session,以处理聊天、房间和笔记消息类型中的文本和媒体

错误修复:

  • 修复 WebSocket 客户端中的频道订阅清理和断开连接逻辑,以确保正确拆卸

增强功能:

  • 使用特定的 HTTP 状态异常和具有指数退避和抖动的 retry_async 装饰器,优化 API 错误处理
  • 增强 WebSocket 客户端,使其在重新连接时自动重新订阅频道,并记录简洁的信息级别摘要以及 DEBUG 级别的完整负载
  • 扩展 API,增加文件管理方法:查找、列出、检查存在性、计算远程 MD5 以及通过轮询和回退进行智能上传和查找
  • 暴露新的适配器配置选项,用于文件上传和下载控制:enable_file_upload、upload_concurrency、upload_folder、allow_insecure_downloads、download_timeout、chunk_size 和 max_download_bytes
  • 改进适配器日志记录,包括详细的组件检查、回退 URL 处理和临时文件清理

文档:

  • 在默认配置中记录新的 Misskey 文件上传和下载设置
Original summary in English

Summary by Sourcery

Update Misskey adapter with extensive feature additions, enhancements, and fixes: implement robust file upload workflows, enrich note creation fields, improve WebSocket handling and retry logic, and expose new configuration options.

New Features:

  • Support concurrent file uploads with configurable concurrency, retries, fallback, and upload-from-url endpoints
  • Enable parsing and embedding of Misskey polls and add cw, renote, channel, and reaction_acceptance fields for rich note creation
  • Introduce unified send_message_with_media and enhance send_by_session to handle text and media across chat, room, and note message types

Bug Fixes:

  • Fix channel subscription cleanup and disconnection logic in WebSocket client to ensure proper teardown

Enhancements:

  • Refine API error handling with specific HTTP status exceptions and a retry_async decorator featuring exponential backoff and jitter
  • Enhance WebSocket client to auto-resubscribe channels on reconnect and log concise info-level summaries alongside full payloads at DEBUG
  • Extend API with file management methods: find, list, check existence, calculate remote MD5, and smart upload-and-find with polling and fallbacks
  • Expose new adapter configuration options for file upload and download control: enable_file_upload, upload_concurrency, upload_folder, allow_insecure_downloads, download_timeout, chunk_size, and max_download_bytes
  • Improve adapter logging with detailed component inspection, fallback URL handling, and temporary file cleanup

Documentation:

  • Document new Misskey file upload and download settings in default configuration

@PaloMiku
Copy link
Contributor Author

PaloMiku commented Oct 9, 2025

image

- 重构了 `misskey_event.py` 中的 `send` 方法,使用新的适配器方法 `send_by_session`,以改进消息处理(包括文件上传)。
- 添加了详细的日志记录,以提高消息发送过程的可追溯性。
- 在 `misskey_utils.py` 中引入了 `FileIDExtractor` 和 `MessagePayloadBuilder` 类,以简化文件 ID 提取和消息载荷构建。
- 在 `misskey_utils.py` 中实现了 MIME 类型检测和文件扩展名解析,以支持多种文件上传。
- 增强了 `resolve_component_url_or_path`,以更好地处理不同类型的组件上传文件。
- 在 `upload_local_with_retries` 中添加了重试逻辑,以优雅地处理不允许的文件类型。
@PaloMiku PaloMiku marked this pull request as ready for review October 9, 2025 13:30
@auto-assign auto-assign bot requested review from Larch-C and Soulter October 9, 2025 13:30
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:480-489` </location>
<code_context>
+from .misskey_api import MisskeyAPI, APIError
+import os
+
+try:
+    import magic  # type: ignore
+except Exception:
</code_context>

<issue_to_address>
**suggestion (performance):** File upload uses blocking file I/O in async context.

Using synchronous file I/O in async code can block the event loop. Switch to aiofiles or another async file library for non-blocking access.

Suggested implementation:

```python
import os
import aiofiles

try:
    import magic  # type: ignore
except Exception:
    pass

```

```python
        # Asynchronous file I/O using aiofiles
        async with aiofiles.open(file_path, "rb") as f:
            file_bytes = await f.read()

```

If there are other places in the file where synchronous file I/O is used for uploads or downloads, you should similarly replace them with `aiofiles` and use `async with` and `await`. Make sure the containing function is declared as `async def` if it isn't already.
</issue_to_address>

### Comment 2
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:782-789` </location>
<code_context>
+        """使用 session 流式读取并计算 MD5,支持下载大小限制"""
+        total = 0
+        m = hashlib.md5()
+        async with self.session.get(
+            url,
+            timeout=aiohttp.ClientTimeout(total=self.download_timeout),
+            ssl=ssl_verify,
+        ) as resp:
+            if resp.status != 200:
+                return None
+            async for chunk in resp.content.iter_chunked(self.chunk_size):
+                if not chunk:
+                    continue
</code_context>

<issue_to_address>
**suggestion:** No explicit handling for very large files in _stream_md5_with_session.

Consider implementing a hard limit or aborting the stream for excessively large files, particularly when max_download_bytes is None, to prevent potential memory issues.

Suggested implementation:

```python
MAX_STREAM_MD5_BYTES = 100 * 1024 * 1024  # 100MB, 可根据实际需求调整

    async def _stream_md5_with_session(
        self, url: str, ssl_verify: bool = True
    ) -> Optional[str]:
        """使用 session 流式读取并计算 MD5,支持下载大小限制和硬性最大下载字节数"""
        total = 0
        m = hashlib.md5()

```

```python
        total = 0
        m = hashlib.md5()

```

```python
        async with self.session.get(
            url,
            timeout=aiohttp.ClientTimeout(total=self.download_timeout),
            ssl=ssl_verify,
        ) as resp:
            if resp.status != 200:
                return None
            async for chunk in resp.content.iter_chunked(self.chunk_size):
                if not chunk:
                    continue
                total += len(chunk)
                if total > MAX_STREAM_MD5_BYTES:
                    logger.warning(f"[Misskey API] 文件过大,已超过最大流式 MD5 限制: {url}")
                    return None
                m.update(chunk)

```
</issue_to_address>

### Comment 3
<location> `astrbot/core/platform/sources/misskey/misskey_adapter.py:449-454` </location>
<code_context>
+                    session_id, text, session, message_chain
+                )
+
+            upload_concurrency = int(
+                self.config.get(
+                    "misskey_upload_concurrency", DEFAULT_UPLOAD_CONCURRENCY
+                )
+            )
+            sem = asyncio.Semaphore(upload_concurrency)
+
+            async def _upload_comp(comp) -> Optional[object]:
</code_context>

<issue_to_address>
**suggestion (bug_risk):** No upper bound on upload_concurrency from config.

Enforce a maximum value for upload_concurrency (e.g., 10) to avoid resource exhaustion from excessive parallel uploads.

```suggestion
            MAX_UPLOAD_CONCURRENCY = 10
            upload_concurrency = int(
                self.config.get(
                    "misskey_upload_concurrency", DEFAULT_UPLOAD_CONCURRENCY
                )
            )
            upload_concurrency = min(upload_concurrency, MAX_UPLOAD_CONCURRENCY)
            sem = asyncio.Semaphore(upload_concurrency)
```
</issue_to_address>

### Comment 4
<location> `astrbot/core/platform/sources/misskey/misskey_adapter.py:851` </location>
<code_context>
                        file_ids=file_ids if file_ids else None,

</code_context>

<issue_to_address>
**suggestion (code-quality):** Replace if-expression with `or` ([`or-if-exp-identity`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/or-if-exp-identity))

```suggestion
                        file_ids=file_ids or None,
```

<br/><details><summary>Explanation</summary>Here we find ourselves setting a value if it evaluates to `True`, and otherwise
using a default.

The 'After' case is a bit easier to read and avoids the duplication of
`input_currency`.

It works because the left-hand side is evaluated first. If it evaluates to
true then `currency` will be set to this and the right-hand side will not be
evaluated. If it evaluates to false the right-hand side will be evaluated and
`currency` will be set to `DEFAULT_CURRENCY`.
</details>
</issue_to_address>

### Comment 5
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:222` </location>
<code_context>
                    f"text={'[no-text]' if not text else text[:80]} | files={has_files} | hidden={is_hidden}"

</code_context>

<issue_to_address>
**suggestion (code-quality):** Swap if/else branches of if expression to remove negation ([`swap-if-expression`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/swap-if-expression))

```suggestion
                    f"text={text[:80] if text else '[no-text]'} | files={has_files} | hidden={is_hidden}"
```

<br/><details><summary>Explanation</summary>Negated conditions are more difficult to read than positive ones, so it is best
to avoid them where we can. By swapping the `if` and `else` conditions around we
can invert the condition and make it positive.
</details>
</issue_to_address>

### Comment 6
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:803` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Raise a specific error instead of the general `Exception` or `BaseException` ([`raise-specific-error`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/raise-specific-error))

<details><summary>Explanation</summary>If a piece of code raises a specific exception type
rather than the generic
[`BaseException`](https://docs.python.org/3/library/exceptions.html#BaseException)
or [`Exception`](https://docs.python.org/3/library/exceptions.html#Exception),
the calling code can:

- get more information about what type of error it is
- define specific exception handling for it

This way, callers of the code can handle the error appropriately.

How can you solve this?

- Use one of the [built-in exceptions](https://docs.python.org/3/library/exceptions.html) of the standard library.
- [Define your own error class](https://docs.python.org/3/tutorial/errors.html#tut-userexceptions) that subclasses `Exception`.

So instead of having code raising `Exception` or `BaseException` like

```python
if incorrect_input(value):
    raise Exception("The input is incorrect")
```

you can have code raising a specific error like

```python
if incorrect_input(value):
    raise ValueError("The input is incorrect")
```

or

```python
class IncorrectInputError(Exception):
    pass


if incorrect_input(value):
    raise IncorrectInputError("The input is incorrect")
```
</details>
</issue_to_address>

### Comment 7
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:878-880` </location>
<code_context>
            tmp_bytes = await self._download_with_existing_session(url)
            if not tmp_bytes:
                tmp_bytes = await self._download_with_temp_session(url)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use `or` for providing a fallback value ([`use-or-for-fallback`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/use-or-for-fallback))

```suggestion
            tmp_bytes = await self._download_with_existing_session(url) or await self._download_with_temp_session(url)

```

<br/><details><summary>Explanation</summary>Thanks to the flexibility of Python's `or` operator, you can use a single
assignment statement, even if a variable can retrieve its value from various
sources. This is shorter and easier to read than using multiple assignments with
`if not` conditions.
</details>
</issue_to_address>

### Comment 8
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:1205` </location>
<code_context>
                "file_ids": file_ids if file_ids else None,

</code_context>

<issue_to_address>
**suggestion (code-quality):** Replace if-expression with `or` ([`or-if-exp-identity`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/or-if-exp-identity))

```suggestion
                "file_ids": file_ids or None,
```

<br/><details><summary>Explanation</summary>Here we find ourselves setting a value if it evaluates to `True`, and otherwise
using a default.

The 'After' case is a bit easier to read and avoids the duplication of
`input_currency`.

It works because the left-hand side is evaluated first. If it evaluates to
true then `currency` will be set to this and the right-hand side will not be
evaluated. If it evaluates to false the right-hand side will be evaluated and
`currency` will be set to `DEFAULT_CURRENCY`.
</details>
</issue_to_address>

### Comment 9
<location> `astrbot/core/platform/sources/misskey/misskey_adapter.py:201-202` </location>
<code_context>
    def _process_poll_data(
        self, message: AstrBotMessage, poll: Dict[str, Any], message_parts: List[str]
    ):
        """处理投票数据,将其添加到消息中"""
        try:
            if not isinstance(message.raw_message, dict):
                message.raw_message = {}
            message.raw_message["poll"] = poll
            setattr(message, "poll", poll)
        except Exception:
            pass

        poll_text = format_poll(poll)
        if poll_text:
            message.message.append(Comp.Plain(poll_text))
            message_parts.append(poll_text)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
        if poll_text := format_poll(poll):
```
</issue_to_address>

### Comment 10
<location> `astrbot/core/platform/sources/misskey/misskey_adapter.py:219-225` </location>
<code_context>
    def _extract_additional_fields(self, session, message_chain) -> Dict[str, Any]:
        """从会话和消息链中提取额外字段"""
        fields = {"cw": None, "poll": None, "renote_id": None, "channel_id": None}

        for comp in message_chain.chain:
            if hasattr(comp, "cw") and getattr(comp, "cw", None):
                fields["cw"] = getattr(comp, "cw")
                break

        if hasattr(session, "extra_data") and isinstance(
            getattr(session, "extra_data", None), dict
        ):
            extra_data = getattr(session, "extra_data")
            fields.update(
                {
                    "poll": extra_data.get("poll"),
                    "renote_id": extra_data.get("renote_id"),
                    "channel_id": extra_data.get("channel_id"),
                }
            )

        return fields

</code_context>

<issue_to_address>
**suggestion (code-quality):** Merge dictionary updates via the union operator ([`dict-assign-update-to-union`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/dict-assign-update-to-union/))

```suggestion
            fields |= {
                "poll": extra_data.get("poll"),
                "renote_id": extra_data.get("renote_id"),
                "channel_id": extra_data.get("channel_id"),
            }
```
</issue_to_address>

### Comment 11
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:194` </location>
<code_context>
    async def _handle_message(self, data: Dict[str, Any]):
        message_type = data.get("type")
        body = data.get("body", {})

        try:
            body = data.get("body") or {}
            channel_summary = None
            if isinstance(body, dict):
                inner = body.get("body") if isinstance(body.get("body"), dict) else body
                note = None
                if isinstance(inner, dict) and isinstance(inner.get("note"), dict):
                    note = inner.get("note")
                text = None
                has_files = False
                is_hidden = False
                note_id = None
                user = None
                if note:
                    text = note.get("text")
                    note_id = note.get("id")
                    files = note.get("files") or []
                    has_files = bool(files)
                    is_hidden = bool(note.get("isHidden"))
                    user = note.get("user", {})

                channel_summary = (
                    f"[Misskey WebSocket] 收到消息类型: {message_type} | "
                    f"note_id={note_id} | user={user.get('username') if user else None} | "
                    f"text={'[no-text]' if not text else text[:80]} | files={has_files} | hidden={is_hidden}"
                )
            else:
                channel_summary = f"[Misskey WebSocket] 收到消息类型: {message_type}"

            logger.info(channel_summary)
        except Exception:
            logger.info(f"[Misskey WebSocket] 收到消息类型: {message_type}")

        logger.debug(
            f"[Misskey WebSocket] 收到完整消息: {json.dumps(data, indent=2, ensure_ascii=False)}"
        )

        if message_type == "channel":
            channel_id = body.get("id")
            event_type = body.get("type")
            event_body = body.get("body", {})

            logger.debug(
                f"[Misskey WebSocket] 频道消息: {channel_id}, 事件类型: {event_type}"
            )

            if channel_id in self.channels:
                channel_type = self.channels[channel_id]
                handler_key = f"{channel_type}:{event_type}"

                if handler_key in self.message_handlers:
                    logger.debug(f"[Misskey WebSocket] 使用处理器: {handler_key}")
                    await self.message_handlers[handler_key](event_body)
                elif event_type in self.message_handlers:
                    logger.debug(f"[Misskey WebSocket] 使用事件处理器: {event_type}")
                    await self.message_handlers[event_type](event_body)
                else:
                    logger.debug(
                        f"[Misskey WebSocket] 未找到处理器: {handler_key} 或 {event_type}"
                    )
                    if "_debug" in self.message_handlers:
                        await self.message_handlers["_debug"](
                            {
                                "type": event_type,
                                "body": event_body,
                                "channel": channel_type,
                            }
                        )

        elif message_type in self.message_handlers:
            logger.debug(f"[Misskey WebSocket] 直接消息处理器: {message_type}")
            await self.message_handlers[message_type](body)
        else:
            logger.debug(f"[Misskey WebSocket] 未处理的消息类型: {message_type}")
            if "_debug" in self.message_handlers:
                await self.message_handlers["_debug"](data)

</code_context>

<issue_to_address>
**issue (code-quality):** Low code quality found in StreamingClient.\_handle\_message - 13% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

<br/><details><summary>Explanation</summary>The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.

How can you solve this?

It might be worth refactoring this function to make it shorter and more readable.

- Reduce the function length by extracting pieces of functionality out into
  their own functions. This is the most important thing you can do - ideally a
  function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
  sits together within the function rather than being scattered.</details>
</issue_to_address>

### Comment 12
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:354-356` </location>
<code_context>
    def __init__(
        self,
        instance_url: str,
        access_token: str,
        *,
        allow_insecure_downloads: bool = False,
        download_timeout: int = 15,
        chunk_size: int = 64 * 1024,
        max_download_bytes: Optional[int] = None,
    ):
        self.instance_url = instance_url.rstrip("/")
        self.access_token = access_token
        self._session: Optional[aiohttp.ClientSession] = None
        self.streaming: Optional[StreamingClient] = None
        # download options
        self.allow_insecure_downloads = bool(allow_insecure_downloads)
        self.download_timeout = int(download_timeout)
        self.chunk_size = int(chunk_size)
        self.max_download_bytes = (
            int(max_download_bytes) if max_download_bytes is not None else None
        )

</code_context>

<issue_to_address>
**suggestion (code-quality):** Remove unnecessary casts to int, str, float or bool [×3] ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))

```suggestion
        self.allow_insecure_downloads = allow_insecure_downloads
        self.download_timeout = download_timeout
        self.chunk_size = chunk_size
```
</issue_to_address>

### Comment 13
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:725` </location>
<code_context>
    async def calculate_url_md5(self, url: str) -> Optional[str]:
        """Calculate MD5 hash of file from URL with fallback strategies"""
        if not url:
            return None
        # 1) 尝试 HEAD 查找 Content-MD5 / ETag(轻量)
        try:
            async with self.session.head(
                url, timeout=aiohttp.ClientTimeout(total=self.download_timeout)
            ) as head_resp:
                if head_resp.status == 200:
                    # Content-MD5 header 通常是 base64(md5)
                    content_md5 = head_resp.headers.get("Content-MD5")
                    if content_md5:
                        try:
                            raw = base64.b64decode(content_md5)
                            hex_md5 = raw.hex()
                            logger.debug(
                                f"[Misskey API] 从 HEAD Content-MD5 获取 md5: {hex_md5}"
                            )
                            return hex_md5
                        except Exception:
                            logger.debug(
                                "[Misskey API] 无法解析 Content-MD5 header,继续流式下载"
                            )

                    # 尝试 ETag(有些服务直接返回十六进制 MD5)
                    etag = head_resp.headers.get("ETag")
                    if etag:
                        etag_val = etag.strip('"')
                        if re.fullmatch(r"[0-9a-fA-F]{32}", etag_val):
                            logger.debug(
                                f"[Misskey API] 从 HEAD ETag 获取 md5: {etag_val}"
                            )
                            return etag_val.lower()
        except Exception:
            logger.debug("[Misskey API] HEAD 请求失败,继续使用流式 GET")

        # 2) 流式 GET(使用现有 session)
        try:
            md5 = await self._stream_md5_with_session(url, ssl_verify=True)
            if md5:
                logger.debug(f"[Misskey API] 流式 MD5 计算成功 (ssl): {md5}")
                return md5
        except Exception as e:
            logger.debug(f"[Misskey API] 流式下载(ssl)失败: {e}")

        # 3) 可选:不安全下载(受配置控制,仅做最后退路)
        if self.allow_insecure_downloads:
            try:
                md5 = await self._stream_md5_with_session(url, ssl_verify=False)
                if md5:
                    logger.warning(
                        f"[Misskey API] 使用不安全下载获取 MD5(ssl 验证已禁用): {url}"
                    )
                    return md5
            except Exception as e:
                logger.debug(f"[Misskey API] 不安全流式下载失败: {e}")

        logger.warning(f"[Misskey API] 无法计算 MD5: {url}")
        return None

</code_context>

<issue_to_address>
**issue (code-quality):** Use named expression to simplify assignment and conditional [×2] ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
</issue_to_address>

### Comment 14
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:851` </location>
<code_context>
    async def upload_and_find_file(
        self,
        url: str,
        name: Optional[str] = None,
        folder_id: Optional[str] = None,
        max_wait_time: float = 30.0,
        check_interval: float = 2.0,
    ) -> Optional[Dict[str, Any]]:
        """
        智能文件上传:先检查重复,再上传,最后轮询查找

        Args:
            url: 文件URL
            name: 文件名(可选)
            folder_id: 文件夹ID(可选)
            max_wait_time: 最大等待时间
            check_interval: 轮询间隔

        Returns:
            包含文件ID和元信息的字典,失败时返回None
        """
        if not url:
            raise APIError("URL不能为空")

        # 优先按文件名在已有文件中查找(避免重复上传)
        try:
            filename = name or url.split("/")[-1].split("?")[0]
            if filename:
                matches = await self.find_files_by_name(filename, folder_id)
                if matches:
                    file_id = matches[0].get("id")
                    logger.debug(f"[Misskey API] 通过名称找到已存在文件: {file_id}")
                    return {"id": file_id, "raw": matches[0], "name_match": True}
        except Exception:
            # 名称查找失败时继续尝试 upload-from-url
            logger.debug("[Misskey API] 名称查找失败或异常,继续处理")

        # 尝试使用 Misskey 的 upload-from-url 接口(服务器端处理远程 URL)
        try:
            upload_result = await self.upload_file_from_url(url, name, folder_id)
            # 处理 upload-from-url 的返回(可能为 accepted 或直接返回文件)
            md5_hash = await self.calculate_url_md5(url)
            return await self._handle_upload_result(
                upload_result, md5_hash, url, max_wait_time, check_interval
            )
        except Exception as e:
            logger.warning(
                f"[Misskey API] upload-from-url 失败,准备回退到下载并本地上传: {e}"
            )

        # 回退:下载远端文件并做本地上传
        try:
            # 使用现有 session 下载内容到临时文件
            tmp_bytes = await self._download_with_existing_session(url)
            if not tmp_bytes:
                tmp_bytes = await self._download_with_temp_session(url)

            if tmp_bytes:
                # 写入临时文件并上传本地文件
                import tempfile

                with tempfile.NamedTemporaryFile(delete=False) as tmpf:
                    tmpf.write(tmp_bytes)
                    tmp_path = tmpf.name

                try:
                    result = await self.upload_file(tmp_path, name, folder_id)
                    return result
                finally:
                    import os

                    try:
                        os.unlink(tmp_path)
                    except Exception:
                        pass
        except Exception as e:
            logger.error(f"[Misskey API] 下载并本地上传回退失败: {e}")

        return None

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Inline variable that is immediately returned ([`inline-immediately-returned-variable`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/inline-immediately-returned-variable/))
</issue_to_address>

### Comment 15
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:960-961` </location>
<code_context>
    async def _poll_by_hash(
        self, md5_hash: str, max_wait_time: float, check_interval: float, url: str
    ) -> Optional[Dict[str, Any]]:
        """通过MD5哈希轮询查找文件"""
        logger.debug(f"[Misskey API] 开始轮询查找文件: {md5_hash}")

        waited_time = 0.0
        while waited_time < max_wait_time:
            try:
                files = await self.find_files_by_hash(md5_hash)
                if files:
                    file_id = files[0].get("id")
                    if file_id:
                        logger.debug(f"[Misskey API] 异步上传完成: {file_id}")
                        return {"id": file_id, "raw": files[0], "async_found": True}
            except Exception as e:
                logger.debug(f"[Misskey API] 轮询查找出错: {e}")

            await asyncio.sleep(check_interval)
            waited_time += check_interval

        # MD5轮询超时,尝试名称匹配
        return await self._fallback_name_search(url)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
                    if file_id := files[0].get("id"):
```
</issue_to_address>

### Comment 16
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:1055-1057` </location>
<code_context>
    async def get_messages(
        self, user_id: str, limit: int = 10, since_id: Optional[str] = None
    ) -> List[Dict[str, Any]]:
        """获取聊天消息历史"""
        data: Dict[str, Any] = {"userId": user_id, "limit": limit}
        if since_id:
            data["sinceId"] = since_id

        result = await self._make_request("chat/messages/user-timeline", data)
        if isinstance(result, list):
            return result
        else:
            logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}")
            return []

</code_context>

<issue_to_address>
**suggestion (code-quality):** Remove unnecessary else after guard condition ([`remove-unnecessary-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-else/))

```suggestion
        logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}")
        return []
```
</issue_to_address>

### Comment 17
<location> `astrbot/core/platform/sources/misskey/misskey_api.py:1163` </location>
<code_context>
    async def _dispatch_message(
        self,
        message_type: str,
        target_id: str,
        text: Optional[str],
        file_ids: List[str],
        **kwargs,
    ) -> Dict[str, Any]:
        """根据消息类型分发到对应的发送方法"""
        if message_type == "chat":
            # 聊天消息使用 fileId (单数)
            payload = {"toUserId": target_id}
            if text:
                payload["text"] = text
            if file_ids:
                if len(file_ids) == 1:
                    payload["fileId"] = file_ids[0]
                else:
                    # 多文件时逐个发送
                    results = []
                    for file_id in file_ids:
                        single_payload = payload.copy()
                        single_payload["fileId"] = file_id
                        result = await self.send_message(single_payload)
                        results.append(result)
                    return {"multiple": True, "results": results}
            return await self.send_message(payload)

        elif message_type == "room":
            # 房间消息使用 fileId (单数)
            payload = {"toRoomId": target_id}
            if text:
                payload["text"] = text
            if file_ids:
                if len(file_ids) == 1:
                    payload["fileId"] = file_ids[0]
                else:
                    # 多文件时逐个发送
                    results = []
                    for file_id in file_ids:
                        single_payload = payload.copy()
                        single_payload["fileId"] = file_id
                        result = await self.send_room_message(single_payload)
                        results.append(result)
                    return {"multiple": True, "results": results}
            return await self.send_room_message(payload)

        elif message_type == "note":
            # 发帖使用 fileIds (复数)
            note_kwargs = {
                "text": text,
                "file_ids": file_ids if file_ids else None,
            }
            # 合并其他参数
            note_kwargs.update(kwargs)
            return await self.create_note(**note_kwargs)

        else:
            raise APIError(f"不支持的消息类型: {message_type}")

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Simplify conditional into switch-like form ([`switch`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/switch/))
- Merge dictionary updates via the union operator [×2] ([`dict-assign-update-to-union`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/dict-assign-update-to-union/))
</issue_to_address>

### Comment 18
<location> `astrbot/core/platform/sources/misskey/misskey_utils.py:26-27` </location>
<code_context>
    @staticmethod
    def extract_file_id(result: Any) -> Optional[str]:
        if not isinstance(result, dict):
            return None

        id_paths = [
            lambda r: r.get("createdFile", {}).get("id"),
            lambda r: r.get("file", {}).get("id"),
            lambda r: r.get("id"),
        ]

        for p in id_paths:
            try:
                fid = p(result)
                if fid:
                    return fid
            except Exception:
                continue

        return None

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
                if fid := p(result):
```
</issue_to_address>

### Comment 19
<location> `astrbot/core/platform/sources/misskey/misskey_utils.py:69` </location>
<code_context>
    @staticmethod
    def build_note_payload(
        text: Optional[str], file_ids: Optional[List[str]] = None, **kwargs
    ) -> Dict[str, Any]:
        payload: Dict[str, Any] = {}
        if text:
            payload["text"] = text
        if file_ids:
            payload["fileIds"] = file_ids
        payload.update(kwargs)
        return payload

</code_context>

<issue_to_address>
**suggestion (code-quality):** Merge dictionary updates via the union operator ([`dict-assign-update-to-union`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/dict-assign-update-to-union/))

```suggestion
        payload |= kwargs
```
</issue_to_address>

### Comment 20
<location> `astrbot/core/platform/sources/misskey/misskey_utils.py:283` </location>
<code_context>
def format_poll(poll: Dict[str, Any]) -> str:
    """将 Misskey 的 poll 对象格式化为可读字符串。"""
    if not poll or not isinstance(poll, dict):
        return ""
    parts = []
    multiple = poll.get("multiple", False)
    choices = poll.get("choices", [])
    parts.append("[投票]")
    parts.append("允许多选" if multiple else "单选")
    text_choices = []
    for idx, c in enumerate(choices, start=1):
        text = c.get("text", "")
        votes = c.get("votes", 0)
        text_choices.append(f"({idx}) {text} [{votes}票]")
    if text_choices:
        parts.append("选项: " + ", ".join(text_choices))
    return " ".join(parts)

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Merge consecutive list appends into a single extend ([`merge-list-appends-into-extend`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/merge-list-appends-into-extend/))
- Move assignment closer to its usage within a block ([`move-assign-in-block`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/move-assign-in-block/))
- Merge extend into list declaration ([`merge-list-extend`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/merge-list-extend/))
- Unwrap a constant iterable constructor ([`unwrap-iterable-construction`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/unwrap-iterable-construction/))
</issue_to_address>

### Comment 21
<location> `astrbot/core/platform/sources/misskey/misskey_utils.py:533-536` </location>
<code_context>
async def resolve_component_url_or_path(
    comp: Any,
) -> Tuple[Optional[str], Optional[str]]:
    """尝试从组件解析可上传的远程 URL 或本地路径。

    返回 (url_candidate, local_path)。两者可能都为 None。
    这个函数尽量不抛异常,调用方可按需处理 None。
    """
    url_candidate = None
    local_path = None

    try:
        if hasattr(comp, "convert_to_file_path"):
            try:
                p = await comp.convert_to_file_path()
                if isinstance(p, str):
                    if p.startswith("http"):
                        url_candidate = p
                    else:
                        local_path = p
            except Exception:
                pass

        if not local_path and hasattr(comp, "get_file"):
            try:
                p = await comp.get_file()
                if isinstance(p, str):
                    if p.startswith("http"):
                        url_candidate = p
                    else:
                        local_path = p
            except Exception:
                pass

        # register_to_file_service or get_file(True) may provide a URL
        if not url_candidate and hasattr(comp, "register_to_file_service"):
            try:
                r = await comp.register_to_file_service()
                if isinstance(r, str) and r.startswith("http"):
                    url_candidate = r
            except Exception:
                pass

        if not url_candidate and hasattr(comp, "get_file"):
            try:
                maybe = await comp.get_file(True)
                if isinstance(maybe, str) and maybe.startswith("http"):
                    url_candidate = maybe
            except Exception:
                pass

        # fallback to common attributes
        if not url_candidate and not local_path:
            for attr in ("file", "url", "path", "src", "source"):
                try:
                    val = getattr(comp, attr, None)
                except Exception:
                    val = None
                if val and isinstance(val, str):
                    if val.startswith("http"):
                        url_candidate = val
                        break
                    else:
                        local_path = val
                        break
    except Exception:
        return None, None

    return url_candidate, local_path

</code_context>

<issue_to_address>
**issue (code-quality):** Hoist repeated code outside conditional statement ([`hoist-statement-from-if`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/hoist-statement-from-if/))
</issue_to_address>

### Comment 22
<location> `astrbot/core/platform/sources/misskey/misskey_utils.py:566` </location>
<code_context>
async def upload_local_with_retries(
    api: Any,
    local_path: str,
    preferred_name: Optional[str],
    folder_id: Optional[str],
) -> Optional[str]:
    """尝试本地上传并在遇到 unallowed 错误时按扩展名重试,返回 file id 或 None。"""
    try:
        res = await api.upload_file(local_path, preferred_name, folder_id)
        if isinstance(res, dict):
            fid = res.get("id") or (res.get("raw") or {}).get("createdFile", {}).get(
                "id"
            )
            if fid:
                return str(fid)
    except Exception as e:
        msg = str(e).lower()
        if "unallowed" in msg or "unallowed_file_type" in msg:
            base = os.path.basename(local_path)
            name_root, ext = os.path.splitext(base)
            try_ext = detect_mime_ext(local_path)
            candidates = []
            if try_ext:
                candidates.append(try_ext)
            candidates.extend([".jpg", ".png", ".txt", ".bin"])
            if ext and len(ext) <= 5 and ext not in candidates:
                candidates.insert(0, ext)
            tried = set()
            for c in candidates:
                try_name = name_root + c
                if try_name in tried:
                    continue
                tried.add(try_name)
                try:
                    r = await api.upload_file(local_path, try_name, folder_id)
                    if isinstance(r, dict):
                        fid = r.get("id") or (r.get("raw") or {}).get(
                            "createdFile", {}
                        ).get("id")
                        if fid:
                            return str(fid)
                except Exception:
                    continue
    return None

</code_context>

<issue_to_address>
**issue (code-quality):** Use named expression to simplify assignment and conditional [×2] ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

We failed to fetch pull request #2986.

You can try again by commenting this pull request with @sourcery-ai review, or contact us for help.

@PaloMiku
Copy link
Contributor Author

文件上传接口在近日继续维护更新,优化代码,暂时不建议合并。

@PaloMiku
Copy link
Contributor Author

PaloMiku commented Oct 15, 2025

文件上传接口在近日继续维护更新,优化代码,暂时不建议合并。

维护完成,重构和精简代码完成。

@PaloMiku
Copy link
Contributor Author

image image

@Soulter Soulter requested a review from Copilot October 15, 2025 14:02
@Soulter Soulter changed the title Feat:Misskey 适配器功能更新和问题修复 feat:Misskey 适配器支持文件上传、投票内容感知功能和重构部分代码 Oct 15, 2025
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR enhances the Misskey adapter with media upload support, richer note creation options (cw, poll, renote, channel), and improved WebSocket handling and retry logic.

  • Adds concurrent file upload with fallback-from-URL and local upload; introduces configurable upload/download options.
  • Improves WebSocket auto-resubscription, smarter retry with backoff + jitter, and better logging.
  • Extends APIs and utilities (visibility resolution, poll formatting, unified send with media).

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
astrbot/core/platform/sources/misskey/misskey_utils.py Adds helpers for file ID extraction, payload building, visibility parsing refactor (with backward-compatible alias), poll formatting, and component URL/path resolution.
astrbot/core/platform/sources/misskey/misskey_event.py Routes event sending through adapter’s send_by_session (to leverage upload), improves fallback path formatting and logging.
astrbot/core/platform/sources/misskey/misskey_api.py Adds smarter retry decorator, new file management APIs, upload-from-URL with fallback to local, unified media sending, and richer create_note parameters; improves WebSocket handling and logging.
astrbot/core/platform/sources/misskey/misskey_adapter.py Wires new config, registers handlers, implements concurrent upload pipeline, poll embedding, visibility handling, and unified sending across chat/room/note.
astrbot/core/config/default.py Exposes new Misskey upload/download and safety settings in default config and UI metadata.

Comment on lines +441 to +450
async def _get_str_value(coro_or_val):
"""辅助函数:统一处理协程或普通值"""
try:
if hasattr(coro_or_val, "__await__"):
result = await coro_or_val
else:
result = coro_or_val
return result if isinstance(result, str) else None
except Exception:
return None
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

Use inspect.isawaitable to detect awaitables rather than checking await directly; it covers more cases (coroutines, Futures, Tasks).

Copilot uses AI. Check for mistakes.

@Soulter Soulter merged commit 23f13ef into AstrBotDevs:master Oct 16, 2025
4 checks passed
@PaloMiku PaloMiku deleted the feature/misskey-adapter-update branch October 17, 2025 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants