mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
624 lines
25 KiB
Python
624 lines
25 KiB
Python
"""
|
||
飞书通知模块的实例管理、投递路由和统一消息接口。
|
||
"""
|
||
|
||
from typing import Any, List, Optional, Tuple, Union
|
||
|
||
from app.application.messaging.channel.admin import (
|
||
register_channel_admin_resolver,
|
||
resolve_config_principal_ids,
|
||
)
|
||
from app.domain.context import Context, MediaInfo
|
||
from app.modules._base.notification import _MessageChannelModuleBase
|
||
from app.modules.feishu.feishu import Feishu
|
||
from app.schemas.message import IncomingMessage, Message, MessageResponse
|
||
from app.schemas.types import ModuleType, NotificationChannel
|
||
|
||
register_channel_admin_resolver(
|
||
NotificationChannel.Feishu,
|
||
lambda config: resolve_config_principal_ids(
|
||
config, "FEISHU_ADMINS", "FEISHU_OPEN_ID"
|
||
),
|
||
)
|
||
|
||
|
||
class FeishuModule(_MessageChannelModuleBase[Feishu]):
|
||
"""
|
||
按通知实例路由飞书消息、附件、卡片更新和处理状态。
|
||
"""
|
||
_notification_user_target_keys = (
|
||
"feishu_openid",
|
||
"feishu_userid",
|
||
"feishu_chat_id",
|
||
)
|
||
_notification_admin_default_config_keys = ("FEISHU_OPEN_ID", "FEISHU_CHAT_ID")
|
||
|
||
def init_module(self) -> None:
|
||
"""
|
||
加载飞书通知实例并绑定渠道类型。
|
||
"""
|
||
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
|
||
self._channel = NotificationChannel.Feishu
|
||
|
||
@staticmethod
|
||
def get_name() -> str:
|
||
"""
|
||
返回配置界面使用的渠道名称。
|
||
|
||
:return: 飞书
|
||
"""
|
||
return "飞书"
|
||
|
||
@staticmethod
|
||
def get_type() -> ModuleType:
|
||
"""
|
||
声明模块属于通知服务。
|
||
|
||
:return: 通知模块类型
|
||
"""
|
||
return ModuleType.Notification
|
||
|
||
@staticmethod
|
||
def get_subtype() -> NotificationChannel:
|
||
"""
|
||
声明模块使用飞书通知渠道。
|
||
|
||
:return: 飞书渠道枚举
|
||
"""
|
||
return NotificationChannel.Feishu
|
||
|
||
@staticmethod
|
||
def get_priority() -> int:
|
||
"""
|
||
返回同一接口的模块调度优先级。
|
||
|
||
:return: 固定为 2;数值越小越先调用
|
||
"""
|
||
return 2
|
||
|
||
def _commands_enabled(self, config: Optional[dict[str, Any]]) -> bool:
|
||
"""
|
||
飞书机器人无斜杠命令概念,开放平台也不提供命令注册/删除 API,。
|
||
跳过命令注册,避免基类默认钩子调用不存在的 client.register_commands。
|
||
"""
|
||
return False
|
||
|
||
def stop(self) -> bool:
|
||
"""
|
||
停止全部飞书实例并等待长连接退出。
|
||
|
||
:return: 全部实例均停止成功时返回 True,否则返回 False
|
||
"""
|
||
return self._stop_service_instances()
|
||
|
||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||
"""
|
||
通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。
|
||
"""
|
||
return None
|
||
|
||
@staticmethod
|
||
def _resolve_message_target(
|
||
message: Message,
|
||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||
"""
|
||
从通知内容解析用户、会话和接收标识类型。
|
||
|
||
要求私聊且有用户时只按用户寻址;否则优先回到原会话,再使用通知用户及渠道目标。
|
||
|
||
:param message: 包含私聊约束、原会话和渠道目标的消息
|
||
:return: 用户标识、会话标识和接收标识类型的三元组;缺少的项为 None
|
||
"""
|
||
userid = str(message.userid).strip() if message.userid else None
|
||
chat_id = None
|
||
receive_id_type = "open_id" if userid else None
|
||
|
||
# 私聊投递只能按用户身份寻址,原会话 ID 可能属于群聊。
|
||
if message.private_delivery and userid:
|
||
return userid, None, None
|
||
|
||
# 回复类消息携带原会话 ID 时,必须发回原会话,
|
||
# 否则群聊 @ 机器人的回复会错误地发送到机器人与用户的私聊窗口。
|
||
original_chat_id = str(message.original_chat_id or "").strip() or None
|
||
if original_chat_id:
|
||
return None, original_chat_id, "chat_id"
|
||
|
||
targets = message.targets or {}
|
||
if not userid and targets:
|
||
open_id = str(targets.get("feishu_openid") or "").strip() or None
|
||
user_id = str(targets.get("feishu_userid") or "").strip() or None
|
||
chat_id = str(targets.get("feishu_chat_id") or "").strip() or None
|
||
if open_id:
|
||
userid = open_id
|
||
receive_id_type = "open_id"
|
||
elif user_id:
|
||
userid = user_id
|
||
receive_id_type = "user_id"
|
||
|
||
return userid, chat_id, receive_id_type
|
||
|
||
def message_parser(
|
||
self, source: str, body: Any, form: Any, args: Any
|
||
) -> Optional[IncomingMessage]:
|
||
"""
|
||
交给来源实例解析统一入口报文。
|
||
|
||
:param source: 通知实例名称
|
||
:param body: JSON 文本、字节或已解析的报文
|
||
:param form: 兼容其他渠道的表单参数,当前不使用
|
||
:param args: 兼容其他渠道的查询参数,当前不使用
|
||
:return: 统一消息;实例缺失或报文被拒绝时返回 None
|
||
"""
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client: Optional[Feishu] = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
return client.parse_message(body)
|
||
|
||
def post_message(self, message: Message, **kwargs: Any) -> None:
|
||
"""
|
||
向符合通知规则的实例发送卡片、附件或语音。
|
||
|
||
:param message: 通知内容和目标;同时有封面与文件时分别发送卡片和附件
|
||
:param kwargs: 兼容模块分发的附加参数,当前不使用
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
userid, chat_id, receive_id_type = self._resolve_message_target(message)
|
||
client: Optional[Feishu] = self.get_instance(conf.name)
|
||
if client:
|
||
if message.image and message.file_path:
|
||
# 普通文件无法嵌入卡片,先发送图文卡片,再单独发送附件,避免图片被 file_path 分支吞掉。
|
||
client.send_notification(
|
||
message=message.model_copy(update={"file_path": None, "file_name": None}),
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
client.send_file(
|
||
file_path=message.file_path,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
file_name=message.file_name,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
elif message.file_path:
|
||
client.send_file(
|
||
file_path=message.file_path,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
title=message.title,
|
||
text=message.text,
|
||
file_name=message.file_name,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
elif message.voice_path:
|
||
client.send_voice(
|
||
voice_path=message.voice_path,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
caption=message.voice_caption,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
else:
|
||
client.send_notification(
|
||
message=message,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
|
||
def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None:
|
||
"""
|
||
向符合通知规则的实例发送媒体候选卡片。
|
||
|
||
:param message: 标题、按钮和接收目标等通知信息
|
||
:param medias: 媒体候选列表,客户端最多展示前 10 项
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
userid, chat_id, receive_id_type = self._resolve_message_target(message)
|
||
client: Optional[Feishu] = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_medias_message(
|
||
message=message,
|
||
medias=medias,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
)
|
||
|
||
def post_torrents_message(self, message: Message, torrents: List[Context]) -> None:
|
||
"""
|
||
向符合通知规则的实例发送种子候选卡片。
|
||
|
||
:param message: 标题、按钮和接收目标等通知信息
|
||
:param torrents: 种子候选列表,客户端最多展示前 10 项
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
userid, chat_id, receive_id_type = self._resolve_message_target(message)
|
||
client: Optional[Feishu] = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_torrents_message(
|
||
message=message,
|
||
torrents=torrents,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
)
|
||
|
||
def edit_message(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
message_id: Union[str, int],
|
||
chat_id: Union[str, int],
|
||
text: str,
|
||
title: Optional[str] = None,
|
||
buttons: Optional[List[List[dict[str, Any]]]] = None,
|
||
metadata: Optional[dict[str, Any]] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
通过指定实例更新普通卡片或流式卡片。
|
||
|
||
:param channel: 目标通知渠道;非飞书时不处理
|
||
:param source: 原消息所属通知实例
|
||
:param message_id: 原消息标识
|
||
:param chat_id: 流式图片补发使用的会话标识
|
||
:param text: 新的正文
|
||
:param title: 普通卡片的新标题
|
||
:param buttons: 新按钮布局;提供按钮时使用普通卡片更新
|
||
:param metadata: 原消息流式状态,客户端会原地更新序号和重试信息
|
||
:return: 更新成功返回 True,失败或实例缺失返回 False;渠道不匹配返回 None
|
||
"""
|
||
if channel != self._channel:
|
||
return None
|
||
for conf in self.get_configs().values():
|
||
if source != conf.name:
|
||
continue
|
||
client: Optional[Feishu] = self.get_instance(conf.name)
|
||
if client and client.edit_message(
|
||
message_id=str(message_id),
|
||
title=title,
|
||
text=text,
|
||
buttons=buttons,
|
||
metadata=metadata,
|
||
chat_id=str(chat_id) if chat_id else None,
|
||
):
|
||
return True
|
||
return False
|
||
|
||
def send_direct_message(self, message: Message) -> Optional[MessageResponse]:
|
||
"""
|
||
发送消息并返回首个成功实例的消息引用。
|
||
|
||
封面与附件组合只以卡片发送结果决定成功,附件发送结果不影响返回值。
|
||
|
||
:param message: 待发送通知;plain 模式通过纯文本保留敏感字符,封面与文件分别发送
|
||
:return: 成功主消息的引用与可选流式状态;没有成功实例时返回 None
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
userid, chat_id, receive_id_type = self._resolve_message_target(message)
|
||
client: Optional[Feishu] = self.get_instance(conf.name)
|
||
if not client:
|
||
continue
|
||
if message.image and message.file_path:
|
||
result = client.send_notification(
|
||
message=message.model_copy(update={"file_path": None, "file_name": None}),
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
if result and result.get("success"):
|
||
client.send_file(
|
||
file_path=message.file_path,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
file_name=message.file_name,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
elif message.file_path:
|
||
result = client.send_file(
|
||
file_path=message.file_path,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
title=message.title,
|
||
text=message.text,
|
||
file_name=message.file_name,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
elif message.voice_path:
|
||
result = client.send_voice(
|
||
voice_path=message.voice_path,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
caption=message.voice_caption,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
elif str(message.parse_mode or "").strip().lower() == "plain":
|
||
# 受保护结果必须绕过 Markdown 卡片,避免密钥字符被解释或改写。
|
||
plain_text = "\n".join(
|
||
part
|
||
for part in (message.title, message.text, message.link)
|
||
if part
|
||
)
|
||
result = client.send_text(
|
||
text=plain_text,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
else:
|
||
result = client.send_notification(
|
||
message=message,
|
||
userid=userid,
|
||
chat_id=chat_id,
|
||
receive_id_type=receive_id_type,
|
||
original_message_id=str(message.original_message_id) if message.original_message_id else None,
|
||
)
|
||
if result and result.get("success"):
|
||
return MessageResponse(
|
||
message_id=result.get("message_id"),
|
||
chat_id=result.get("chat_id"),
|
||
channel=NotificationChannel.Feishu,
|
||
source=conf.name,
|
||
metadata=result.get("metadata"),
|
||
success=True,
|
||
)
|
||
return None
|
||
|
||
def download_feishu_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]:
|
||
"""
|
||
下载飞书图片引用并编码为数据地址。
|
||
|
||
优先下载原消息资源,失败后尝试机器人上传的图片接口。
|
||
|
||
:param image_ref: feishu://image/ 引用,可携带原消息标识
|
||
:param source: 用于下载的通知实例名称
|
||
:return: Base64 图片数据地址;引用无效、实例缺失或下载失败返回 None
|
||
"""
|
||
if not image_ref or not image_ref.startswith("feishu://image/"):
|
||
return None
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
resource_path = image_ref.replace("feishu://image/", "", 1)
|
||
message_id = None
|
||
image_key = resource_path
|
||
if "/" in resource_path:
|
||
message_id, image_key = resource_path.split("/", 1)
|
||
message_id = message_id.strip() or None
|
||
image_key = image_key.strip()
|
||
downloaded = None
|
||
if message_id:
|
||
downloaded = client.download_message_resource_bytes(
|
||
message_id=message_id,
|
||
file_key=image_key,
|
||
resource_type="image",
|
||
)
|
||
if not downloaded:
|
||
downloaded = client.download_image_bytes(image_key)
|
||
if not downloaded:
|
||
return None
|
||
content, _, content_type = downloaded
|
||
mime_type = content_type or "image/jpeg"
|
||
import base64
|
||
|
||
return f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
|
||
|
||
def download_feishu_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]:
|
||
"""
|
||
下载飞书文件或音频引用的原始内容。
|
||
|
||
带原消息时依次尝试音频资源和文件资源,随后回退到机器人文件下载接口。
|
||
|
||
:param file_ref: feishu://file/ 引用,可携带 om_ 开头的原消息标识
|
||
:param source: 用于下载的通知实例名称
|
||
:return: 资源字节;引用无效、实例缺失或下载失败返回 None
|
||
"""
|
||
if not file_ref or not file_ref.startswith("feishu://file/"):
|
||
return None
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
parts = [
|
||
part.strip()
|
||
for part in file_ref.replace("feishu://file/", "", 1).split("/")
|
||
if part.strip()
|
||
]
|
||
file_key = ""
|
||
downloaded = None
|
||
if len(parts) >= 2 and parts[0].startswith("om_"):
|
||
message_id, file_key = parts[0], parts[1]
|
||
downloaded = client.download_message_resource_bytes(
|
||
message_id=message_id,
|
||
file_key=file_key,
|
||
resource_type="audio",
|
||
)
|
||
if not downloaded:
|
||
downloaded = client.download_message_resource_bytes(
|
||
message_id=message_id,
|
||
file_key=file_key,
|
||
resource_type="file",
|
||
)
|
||
else:
|
||
file_key = parts[0] if parts else ""
|
||
if not file_key:
|
||
return None
|
||
if not downloaded:
|
||
downloaded = client.download_file_bytes(file_key)
|
||
if not downloaded:
|
||
return None
|
||
content, _, _ = downloaded
|
||
return content
|
||
|
||
def add_feishu_message_reaction(
|
||
self,
|
||
message_id: str,
|
||
emoji_type: str,
|
||
source: str,
|
||
) -> Optional[str]:
|
||
"""
|
||
通过指定实例给消息添加表情回应。
|
||
|
||
:param message_id: 目标消息标识
|
||
:param emoji_type: 飞书表情名称
|
||
:param source: 通知实例名称
|
||
:return: 回应标识;实例缺失或接口失败时返回 None
|
||
"""
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
return client.add_message_reaction(message_id=message_id, emoji_type=emoji_type)
|
||
|
||
def delete_feishu_message_reaction(
|
||
self,
|
||
message_id: str,
|
||
reaction_id: str,
|
||
source: str,
|
||
) -> Optional[bool]:
|
||
"""
|
||
通过指定实例删除消息表情回应。
|
||
|
||
:param message_id: 目标消息标识
|
||
:param reaction_id: 待删除回应标识
|
||
:param source: 通知实例名称
|
||
:return: 删除成功返回 True,实例缺失或接口失败返回 False
|
||
"""
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return False
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return False
|
||
return client.delete_message_reaction(message_id=message_id, reaction_id=reaction_id)
|
||
|
||
def mark_message_processing_started(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Optional[Union[str, int]] = None,
|
||
message_id: Optional[Union[str, int]] = None,
|
||
chat_id: Optional[Union[str, int]] = None,
|
||
text: Optional[str] = None,
|
||
) -> Optional[dict[str, Any]]:
|
||
"""
|
||
为有文本的普通消息添加“正在处理”表情。
|
||
|
||
:param channel: 来源渠道;非飞书时忽略
|
||
:param source: 来源通知实例
|
||
:param userid: 发送者标识,保存在状态中供后续使用
|
||
:param message_id: 需要标记的原消息标识
|
||
:param chat_id: 原会话标识,保存在状态中
|
||
:param text: 用于排除空内容和 CALLBACK: 按钮回调的正文
|
||
:return: 含回应标识的处理状态;不适用或添加失败返回 None
|
||
"""
|
||
if channel != self._channel:
|
||
return None
|
||
if not message_id or not text or str(text).startswith("CALLBACK:"):
|
||
return None
|
||
reaction_id = self.add_feishu_message_reaction(
|
||
message_id=str(message_id),
|
||
emoji_type=Feishu.PROCESSING_REACTION_EMOJI,
|
||
source=source,
|
||
)
|
||
if not reaction_id:
|
||
return None
|
||
return {
|
||
"channel": channel.value,
|
||
"source": source,
|
||
"userid": userid,
|
||
"message_id": str(message_id),
|
||
"chat_id": str(chat_id) if chat_id else None,
|
||
"metadata": {
|
||
"kind": "reaction",
|
||
"reaction_id": str(reaction_id),
|
||
"emoji_type": Feishu.PROCESSING_REACTION_EMOJI,
|
||
},
|
||
}
|
||
|
||
def mark_message_processing_finished(
|
||
self,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Optional[Union[str, int]] = None,
|
||
message_id: Optional[Union[str, int]] = None,
|
||
chat_id: Optional[Union[str, int]] = None,
|
||
status: Optional[dict[str, Any]] = None,
|
||
) -> Optional[bool]:
|
||
"""
|
||
根据起始处理状态删除“正在处理”表情。
|
||
|
||
:param channel: 来源渠道;非飞书时忽略
|
||
:param source: 来源通知实例
|
||
:param userid: 兼容统一接口的用户标识,当前不使用
|
||
:param message_id: 状态中无消息标识时使用的原消息标识
|
||
:param chat_id: 兼容统一接口的会话标识,当前不使用
|
||
:param status: 起始阶段返回的处理状态,必须包含回应标识
|
||
:return: 删除成功返回 True;缺少状态或删除失败返回 False;渠道不匹配返回 None
|
||
"""
|
||
if channel != self._channel:
|
||
return None
|
||
metadata = (status or {}).get("metadata") or {}
|
||
target_message_id = (status or {}).get("message_id") or message_id
|
||
reaction_id = metadata.get("reaction_id")
|
||
if not target_message_id or not reaction_id:
|
||
return False
|
||
return self.delete_feishu_message_reaction(
|
||
message_id=str(target_message_id),
|
||
reaction_id=str(reaction_id),
|
||
source=source,
|
||
)
|
||
|
||
def finalize_message(self, response: MessageResponse) -> bool:
|
||
"""
|
||
为流式卡片提交下一个序号并关闭流式展示。
|
||
|
||
:param response: 包含飞书来源及 feishu_streaming 状态的发送结果
|
||
:return: 成功关闭返回 True;渠道、状态或实例不适用及接口失败返回 False
|
||
"""
|
||
if response.channel != self._channel or not isinstance(response.metadata, dict):
|
||
return False
|
||
raw_stream_meta = response.metadata.get("feishu_streaming")
|
||
if not isinstance(raw_stream_meta, dict):
|
||
return False
|
||
stream_meta: dict[str, Any] = raw_stream_meta
|
||
card_id = str(stream_meta.get("card_id") or "").strip()
|
||
if not card_id:
|
||
return False
|
||
client_config = self.get_config(response.source)
|
||
if not client_config:
|
||
return False
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return False
|
||
sequence = int(stream_meta.get("sequence") or 0) + 1
|
||
return client.close_streaming_card(card_id=card_id, sequence=sequence)
|