Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
49 changes: 49 additions & 0 deletions astrbot/core/platform/sources/slack/session_codec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
THREAD_SESSION_MARKER = "__thread__"
LEGACY_GROUP_SESSION_PREFIX = "group_"
SLACK_SAFE_TEXT_FALLBACK = "message"


def encode_thread_session_id(channel_id: str, thread_ts: str) -> str:
if not channel_id or not thread_ts:
return channel_id
return f"{channel_id}{THREAD_SESSION_MARKER}{thread_ts}"


def decode_slack_session_id(session_id: str) -> tuple[str, str | None]:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
if not session_id:
return "", None

if THREAD_SESSION_MARKER in session_id:
channel_id, thread_ts = session_id.split(THREAD_SESSION_MARKER, 1)
# Do not fallback to legacy parsing once thread marker is detected.
# Keep decoded channel_id even if thread_ts is missing.
return channel_id, thread_ts or None

# Backward compatibility for historical IDs like "group_<channel_id>".
if session_id.startswith(LEGACY_GROUP_SESSION_PREFIX):
return session_id[len(LEGACY_GROUP_SESSION_PREFIX) :], None

return session_id, None


def resolve_slack_message_target(
session_id: str,
*,
raw_message: dict | None = None,
group_id: str = "",
sender_id: str = "",
) -> tuple[str, str | None]:
parsed_channel_id, parsed_thread_ts = decode_slack_session_id(session_id)

raw_channel_id = ""
raw_thread_ts = None
if isinstance(raw_message, dict):
raw_channel = raw_message.get("channel")
if raw_channel is not None and raw_channel != "":
raw_channel_id = str(raw_channel)
raw_thread = raw_message.get("thread_ts")
if raw_thread is not None and raw_thread != "":
raw_thread_ts = str(raw_thread)

channel_id = group_id or raw_channel_id or parsed_channel_id or sender_id
return channel_id, raw_thread_ts or parsed_thread_ts
59 changes: 39 additions & 20 deletions astrbot/core/platform/sources/slack/slack_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@

from ...register import register_platform_adapter
from .client import SlackSocketClient, SlackWebhookClient
from .session_codec import (
SLACK_SAFE_TEXT_FALLBACK,
encode_thread_session_id,
resolve_slack_message_target,
)
from .slack_event import SlackMessageEvent


Expand Down Expand Up @@ -86,33 +91,42 @@ async def send_by_session(
message_chain=message_chain,
web_client=self.web_client,
)
safe_text = text or SLACK_SAFE_TEXT_FALLBACK

try:
if session.message_type == MessageType.GROUP_MESSAGE:
# 发送到频道
channel_id = (
session.session_id.split("_")[-1]
if "_" in session.session_id
else session.session_id
)
await self.web_client.chat_postMessage(
channel=channel_id,
text=text,
blocks=blocks if blocks else None,
)
else:
# 发送私信
await self.web_client.chat_postMessage(
channel=session.session_id,
text=text,
blocks=blocks if blocks else None,
)
channel_id, thread_ts = resolve_slack_message_target(
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
session_id=session.session_id,
sender_id=session.session_id,
)
message_payload = {
"channel": channel_id,
"text": safe_text,
"blocks": blocks if blocks else None,
}
if thread_ts:
message_payload["thread_ts"] = thread_ts
await self.web_client.chat_postMessage(**message_payload)
except Exception as e:
logger.error(f"Slack 发送消息失败: {e}")

await super().send_by_session(session, message_chain)

@staticmethod
def _normalize_event_payload(event: dict) -> dict:
# Socket Mode may deliver thread updates as message_replied envelopes.
# Unwrap nested "message" payload to preserve user/text/thread_ts fields.
if event.get("subtype") == "message_replied":
nested_message = event.get("message")
if isinstance(nested_message, dict):
merged = dict(event)
merged.update(nested_message)
if not merged.get("channel"):
merged["channel"] = event.get("channel", "")
return merged
return event

async def convert_message(self, event: dict) -> AstrBotMessage:
event = self._normalize_event_payload(event)
logger.debug(f"[slack] RawMessage {event}")

abm = AstrBotMessage()
Expand Down Expand Up @@ -146,7 +160,12 @@ async def convert_message(self, event: dict) -> AstrBotMessage:
abm.group_id = channel_id

# 设置会话ID
if abm.type == MessageType.GROUP_MESSAGE:
channel_id_for_session = str(channel_id or "")
thread_ts = str(event.get("thread_ts", "") or "")
if thread_ts and channel_id_for_session:
# Slack threads can appear in channels and DMs.
abm.session_id = encode_thread_session_id(channel_id_for_session, thread_ts)
elif abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.group_id
else:
abm.session_id = user_id
Expand Down
89 changes: 58 additions & 31 deletions astrbot/core/platform/sources/slack/slack_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
)
from astrbot.api.platform import Group, MessageMember

from .session_codec import SLACK_SAFE_TEXT_FALLBACK, resolve_slack_message_target

SLACK_IMAGE_FALLBACK_TEXT = "[image]"
SLACK_FILE_FALLBACK_TEMPLATE = "[file:{name}]"
SLACK_GENERIC_FALLBACK_TEXT = "[message]"
SLACK_IMAGE_UPLOAD_FAILED_TEXT = "Image upload failed"
SLACK_FILE_UPLOAD_FAILED_TEXT = "File upload failed"


class SlackMessageEvent(AstrMessageEvent):
def __init__(
Expand Down Expand Up @@ -54,7 +62,7 @@ async def _from_segment_to_slack_block(
logger.error(f"Slack file upload failed: {response['error']}")
return {
"type": "section",
"text": {"type": "mrkdwn", "text": "图片上传失败"},
"text": {"type": "mrkdwn", "text": SLACK_IMAGE_UPLOAD_FAILED_TEXT},
}
image_url = cast(list, response["files"])[0]["url_private"]
logger.debug(f"Slack file upload response: {response}")
Expand All @@ -76,14 +84,14 @@ async def _from_segment_to_slack_block(
logger.error(f"Slack file upload failed: {response['error']}")
return {
"type": "section",
"text": {"type": "mrkdwn", "text": "文件上传失败"},
"text": {"type": "mrkdwn", "text": SLACK_FILE_UPLOAD_FAILED_TEXT},
}
file_url = cast(list, response["files"])[0]["permalink"]
return {
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"文件: <{file_url}|{segment.name or '文件'}>",
"text": f"file: <{file_url}|{segment.name or 'file'}>",
},
}

Expand All @@ -95,10 +103,12 @@ async def _parse_slack_blocks(
"""解析成 Slack 块格式"""
blocks = []
text_content = ""
fallback_parts = []

for segment in message_chain.chain:
if isinstance(segment, Plain):
text_content += segment.text
fallback_parts.append(segment.text)
else:
# 如果有文本内容,先添加文本块
if text_content.strip():
Expand All @@ -117,58 +127,75 @@ async def _parse_slack_blocks(
)
if block:
blocks.append(block)
if isinstance(segment, Image):
fallback_parts.append(SLACK_IMAGE_FALLBACK_TEXT)
elif isinstance(segment, File):
fallback_parts.append(
SLACK_FILE_FALLBACK_TEMPLATE.format(
name=segment.name or "file",
),
)
else:
fallback_parts.append(SLACK_GENERIC_FALLBACK_TEXT)

# 如果最后还有文本内容
if text_content.strip():
blocks.append(
{"type": "section", "text": {"type": "mrkdwn", "text": text_content}},
)

return blocks, "" if blocks else text_content
fallback_text = "".join(fallback_parts).strip() or SLACK_SAFE_TEXT_FALLBACK
return blocks, fallback_text if blocks else text_content

def _resolve_target(self) -> tuple[str, str | None]:
return resolve_slack_message_target(
session_id=self.session_id,
raw_message=getattr(self.message_obj, "raw_message", None),
group_id=self.get_group_id(),
sender_id=self.get_sender_id(),
)

async def send(self, message: MessageChain) -> None:
blocks, text = await SlackMessageEvent._parse_slack_blocks(
message,
self.web_client,
)
safe_text = text or SLACK_SAFE_TEXT_FALLBACK

try:
if self.get_group_id():
# 发送到频道
await self.web_client.chat_postMessage(
channel=self.get_group_id(),
text=text,
blocks=blocks or None,
)
else:
# 发送私信
await self.web_client.chat_postMessage(
channel=self.get_sender_id(),
text=text,
blocks=blocks or None,
)
channel_id, thread_ts = self._resolve_target()
message_payload = {
"channel": channel_id,
"text": safe_text,
"blocks": blocks or None,
}
if thread_ts:
message_payload["thread_ts"] = thread_ts
await self.web_client.chat_postMessage(**message_payload)
except Exception:
# 如果块发送失败,尝试只发送文本
parts = []
for segment in message.chain:
if isinstance(segment, Plain):
parts.append(segment.text)
elif isinstance(segment, File):
parts.append(f" [文件: {segment.name}] ")
parts.append(
SLACK_FILE_FALLBACK_TEMPLATE.format(
name=segment.name or "file",
),
)
elif isinstance(segment, Image):
parts.append(" [图片] ")
fallback_text = "".join(parts)
parts.append(SLACK_IMAGE_FALLBACK_TEXT)
fallback_text = "".join(parts) or SLACK_SAFE_TEXT_FALLBACK

if self.get_group_id():
await self.web_client.chat_postMessage(
channel=self.get_group_id(),
text=fallback_text,
)
else:
await self.web_client.chat_postMessage(
channel=self.get_sender_id(),
text=fallback_text,
)
channel_id, thread_ts = self._resolve_target()
fallback_payload = {
"channel": channel_id,
"text": fallback_text,
}
if thread_ts:
fallback_payload["thread_ts"] = thread_ts
await self.web_client.chat_postMessage(**fallback_payload)

await super().send(message)

Expand Down
Loading