Compare commits

...

12 Commits

Author SHA1 Message Date
Junyan Qin
326aad3c00 chore: release v3.4.6 2025-02-04 20:57:06 +08:00
Junyan Qin
493c2e9a16 chore: update readme 2025-02-04 20:50:56 +08:00
Junyan Qin (Chin)
51a87e28e2 Merge pull request #1016 from wangcham/bugfix-branch
fix: add support for qq official webhook
2025-02-04 20:49:36 +08:00
Junyan Qin
be2ff20f4b chore: migration for qqofficial 2025-02-04 20:48:47 +08:00
Junyan Qin (Chin)
19c6b2fc32 Merge branch 'master' into bugfix-branch 2025-02-04 20:39:52 +08:00
Junyan Qin (Chin)
5d249f441b Merge pull request #1015 from RockChinQ/feat/gewechat
feat: add `gewechat` adapter
2025-02-04 20:38:05 +08:00
Junyan Qin
852254eaef feat: add gewechat adapter 2025-02-04 19:37:40 +08:00
wangcham
43ea64befa fix: add support for webhook qq official 2025-02-04 06:35:51 -05:00
Junyan Qin
0f2cb58897 fix: forward msg send fail in lark and discord 2025-02-04 12:07:15 +08:00
Junyan Qin
dbece6af7f chore: release v3.4.5.2 2025-02-04 00:17:46 +08:00
Junyan Qin (Chin)
b1e68182bd Merge pull request #1013 from RockChinQ/feat/marketplace
feat: add marketplace
2025-02-04 00:17:09 +08:00
Junyan Qin
45a64bea78 feat: add marketplace 2025-02-04 00:14:45 +08:00
28 changed files with 1619 additions and 50 deletions

2
.gitignore vendored
View File

@@ -37,4 +37,4 @@ botpy.log*
/poc
/libs/wecom_api/test.py
/venv
/jp-tyo-churros-05.rockchin.top

View File

@@ -32,7 +32,7 @@
## ✨ Features
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态能力并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、飞书、Discord后续还将支持个人微信、WhatsApp、Telegram 等平台。
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态能力并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、飞书、Discord、个人微信,后续还将支持 WhatsApp、Telegram 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。
- 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;丰富生态,目前已有数十个[插件](https://docs.langbot.app/plugin/plugin-intro.html)
- 😻 [New] Web 管理面板:支持通过浏览器管理 LangBot 实例,具体支持功能,查看[文档](https://docs.langbot.app/webui/intro.html)
@@ -84,7 +84,8 @@
| 企业微信 | ✅ | |
| 飞书 | ✅ | |
| Discord | ✅ | |
| 个人微信 | 🚧 | |
| 个人微信 | | 使用 [Gewechat](https://github.com/Devo919/Gewechat) 接入 |
| Telegram | 🚧 | |
| WhatsApp | 🚧 | |
| 钉钉 | 🚧 | |

View File

274
libs/qq_official_api/api.py Normal file
View File

@@ -0,0 +1,274 @@
import time
from quart import request
import base64
import binascii
import httpx
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
from pkg.platform.types import events as platform_events, message as platform_message
import aiofiles
from .qqofficialevent import QQOfficialEvent
import json
import hmac
import base64
import hashlib
import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519
from .qqofficialevent import QQOfficialEvent
def handle_validation(body: dict, bot_secret: str):
# bot正确的secert是32位的此处仅为了适配演示demo
while len(bot_secret) < 32:
bot_secret = bot_secret * 2
bot_secret = bot_secret[:32]
# 实际使用场景中以上三行内容可清除
seed_bytes = bot_secret.encode()
signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed_bytes)
msg = body['d']['event_ts'] + body['d']['plain_token']
msg_bytes = msg.encode()
signature = signing_key.sign(msg_bytes)
signature_hex = signature.hex()
response = {
"plain_token": body['d']['plain_token'],
"signature": signature_hex
}
return response
class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str):
self.app = Quart(__name__)
self.app.add_url_rule(
"/callback/command",
"handle_callback",
self.handle_callback_request,
methods=["GET", "POST"],
)
self.secret = secret
self.token = token
self.app_id = app_id
self._message_handlers = {
}
self.base_url = "https://api.sgroup.qq.com"
self.access_token = ""
self.access_token_expiry_time = None
async def check_access_token(self):
"""检查access_token是否存在"""
if not self.access_token or await self.is_token_expired():
return False
return bool(self.access_token and self.access_token.strip())
async def get_access_token(self):
"""获取access_token"""
url = "https://bots.qq.com/app/getAppAccessToken"
async with httpx.AsyncClient() as client:
params = {
"appId":self.app_id,
"clientSecret":self.secret,
}
headers = {
"content-type":"application/json",
}
try:
response = await client.post(url,json=params,headers=headers)
if response.status_code == 200:
response_data = response.json()
access_token = response_data.get("access_token")
expires_in = int(response_data.get("expires_in",7200))
self.access_token_expiry_time = time.time() + expires_in - 60
if access_token:
self.access_token = access_token
except Exception as e:
raise Exception(f"获取access_token失败: {e}")
async def handle_callback_request(self):
"""处理回调请求"""
try:
# 读取请求数据
body = await request.get_data()
payload = json.loads(body)
# 验证是否为回调验证请求
if payload.get("op") == 13:
# 生成签名
response = handle_validation(payload, self.secret)
return response
if payload.get("op") == 0:
message_data = await self.get_message(payload)
if message_data:
event = QQOfficialEvent.from_payload(message_data)
await self._handle_message(event)
return {"code": 0, "message": "success"}
except Exception as e:
traceback.print_exc()
return {"error": str(e)}, 400
async def run_task(self, host: str, port: int, *args, **kwargs):
"""启动 Quart 应用"""
await self.app.run_task(host=host, port=port, *args, **kwargs)
def on_message(self, msg_type: str):
"""注册消息类型处理器"""
def decorator(func: Callable[[platform_events.Event], None]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
async def _handle_message(self, event:QQOfficialEvent):
"""处理消息事件"""
msg_type = event.t
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def get_message(self,msg:dict) -> Dict[str,Any]:
"""获取消息"""
message_data = {
"t": msg.get("t",{}),
"user_openid": msg.get("d",{}).get("author",{}).get("user_openid",{}),
"timestamp": msg.get("d",{}).get("timestamp",{}),
"d_author_id": msg.get("d",{}).get("author",{}).get("id",{}),
"content": msg.get("d",{}).get("content",{}),
"d_id": msg.get("d",{}).get("id",{}),
"id": msg.get("id",{}),
"channel_id": msg.get("d",{}).get("channel_id",{}),
"username": msg.get("d",{}).get("author",{}).get("username",{}),
"guild_id": msg.get("d",{}).get("guild_id",{}),
"member_openid": msg.get("d",{}).get("author",{}).get("openid",{}),
"group_openid": msg.get("d",{}).get("group_openid",{})
}
attachments = msg.get("d", {}).get("attachments", [])
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
image_attachments_type = [attachment['content_type'] for attachment in attachments if await self.is_image(attachment)]
if image_attachments:
message_data["image_attachments"] = image_attachments[0]
message_data["content_type"] = image_attachments_type[0]
else:
message_data["image_attachments"] = None
return message_data
async def is_image(self,attachment:dict) -> bool:
"""判断是否为图片附件"""
content_type = attachment.get("content_type","")
return content_type.startswith("image/")
async def send_private_text_msg(self,user_openid:str,content:str,msg_id:str):
"""发送私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/v2/users/" + user_openid + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
data = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=data)
if response.status_code == 200:
return
else:
raise ValueError(response)
async def send_group_text_msg(self,group_openid:str,content:str,msg_id:str):
"""发送群聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/v2/groups/" + group_openid + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
data = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=data)
if response.status_code == 200:
return
else:
raise Exception(response.read().decode())
async def send_channle_group_text_msg(self,channel_id:str,content:str,msg_id:str):
"""发送频道群聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/channels/" + channel_id + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
params = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=params)
if response.status_code == 200:
return True
else:
raise Exception(response)
async def send_channle_private_text_msg(self,guild_id:str,content:str,msg_id:str):
"""发送频道私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + "/dms/" + guild_id + "/messages"
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"QQBot {self.access_token}",
"Content-Type": "application/json",
}
params = {
"content": content,
"msg_type": 0,
"msg_id": msg_id,
}
response = await client.post(url,headers=headers,json=params)
if response.status_code == 200:
return True
else:
raise Exception(response)
async def is_token_expired(self):
"""检查token是否过期"""
if self.access_token_expiry_time is None:
return True
return time.time() > self.access_token_expiry_time

View File

@@ -0,0 +1,114 @@
from typing import Dict, Any, Optional
class QQOfficialEvent(dict):
@staticmethod
def from_payload(payload: Dict[str, Any]) -> Optional["QQOfficialEvent"]:
try:
event = QQOfficialEvent(payload)
return event
except KeyError:
return None
@property
def t(self) -> str:
"""
事件类型
"""
return self.get("t", "")
@property
def user_openid(self) -> str:
"""
用户openid
"""
return self.get("user_openid",{})
@property
def timestamp(self) -> str:
"""
时间戳
"""
return self.get("timestamp",{})
@property
def d_author_id(self) -> str:
"""
作者id
"""
return self.get("id",{})
@property
def content(self) -> str:
"""
内容
"""
return self.get("content",'')
@property
def d_id(self) -> str:
"""
d_id
"""
return self.get("d_id",{})
@property
def id(self) -> str:
"""
消息idmsg_id
"""
return self.get("id",{})
@property
def channel_id(self) -> str:
"""
频道id
"""
return self.get("channel_id",{})
@property
def username(self) -> str:
"""
用户名
"""
return self.get("username",{})
@property
def guild_id(self) -> str:
"""
频道id
"""
return self.get("guild_id",{})
@property
def member_openid(self) -> str:
"""
成员openid
"""
return self.get("openid",{})
@property
def attachments(self) -> str:
"""
附件url
"""
url = self.get("image_attachments", "")
if url and not url.startswith("https://"):
url = "https://" + url
return url
@property
def group_openid(self) -> str:
"""
群组id
"""
return self.get("group_openid",{})
@property
def content_type(self) -> str:
"""
文件类型
"""
return self.get("content_type","")

View File

@@ -27,7 +27,9 @@ required_deps = {
"jwt": "pyjwt",
"Crypto": "pycryptodome",
"lark_oapi": "lark-oapi",
"discord": "discord.py"
"discord": "discord.py",
"cryptography": "cryptography",
"gewechat_client": "gewechat-client"
}

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("gewechat-config", 25)
class GewechatConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'gewechat':
return False
return True
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append({
"adapter": "gewechat",
"enable": False,
"gewechat_url": "http://your-gewechat-server:2531",
"port": 2286,
"callback_url": "http://your-callback-url:2286/gewechat/callback",
"app_id": "",
"token": ""
})
await self.ap.platform_cfg.dump_config()

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("qqofficial-config", 26)
class QQOfficialConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'qqofficial':
return False
return True
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append({
"adapter": "qqofficial",
"enable": False,
"appid": "",
"secret": "",
"port": 2284,
"token": ""
})
await self.ap.platform_cfg.dump_config()

View File

@@ -8,8 +8,8 @@ from ..migrations import m001_sensitive_word_migration, m002_openai_config_migra
from ..migrations import m005_deepseek_cfg_completion, m006_vision_config, m007_qcg_center_url, m008_ad_fixwin_config_migrate, m009_msg_truncator_cfg
from ..migrations import m010_ollama_requester_config, m011_command_prefix_config, m012_runner_config, m013_http_api_config, m014_force_delay_config
from ..migrations import m015_gitee_ai_config, m016_dify_service_api, m017_dify_api_timeout_params, m018_xai_config, m019_zhipuai_config
from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config, m024_discord_config
from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config, m024_discord_config, m025_gewechat_config
from ..migrations import m026_qqofficial_config
@stage.stage_class("MigrationStage")
class MigrationStage(stage.BootingStage):

View File

@@ -9,30 +9,8 @@ from ....core import entities as core_entities
from ....platform.types import message as platform_message
class ForwardMessageDiaplay(pydantic.BaseModel):
title: str = "群聊的聊天记录"
brief: str = "[聊天记录]"
source: str = "聊天记录"
preview: typing.List[str] = []
summary: str = "查看x条转发消息"
class Forward(platform_message.MessageComponent):
"""合并转发。"""
type: str = "Forward"
"""消息组件类型。"""
display: ForwardMessageDiaplay
"""显示信息"""
node_list: typing.List[platform_message.ForwardMessageNode]
"""转发消息节点列表。"""
def __init__(self, *args, **kwargs):
if len(args) == 1:
self.node_list = args[0]
super().__init__(**kwargs)
super().__init__(*args, **kwargs)
def __str__(self):
return '[聊天记录]'
ForwardMessageDiaplay = platform_message.ForwardMessageDiaplay
Forward = platform_message.Forward
@strategy_model.strategy_class("forward")

View File

@@ -7,6 +7,8 @@ import logging
import asyncio
import traceback
from .sources import qqofficial
# FriendMessage, Image, MessageChain, Plain
from ..platform import adapter as msadapter
@@ -37,7 +39,7 @@ class PlatformManager:
async def initialize(self):
from .sources import nakuru, aiocqhttp, qqbotpy, wecom, lark, discord
from .sources import nakuru, aiocqhttp, qqbotpy, qqofficial, wecom, lark, discord, gewechat
async def on_friend_message(event: platform_events.FriendMessage, adapter: msadapter.MessageSourceAdapter):
@@ -100,6 +102,35 @@ class PlatformManager:
if len(self.adapters) == 0:
self.ap.logger.warning('未运行平台适配器,请根据文档配置并启用平台适配器。')
async def write_back_config(self, adapter_inst: msadapter.MessageSourceAdapter, config: dict):
index = -2
for i, adapter in enumerate(self.adapters):
if adapter == adapter_inst:
index = i
break
if index == -2:
raise Exception('平台适配器未找到')
# 只修改启用的适配器
real_index = -1
for i, adapter in enumerate(self.ap.platform_cfg.data['platform-adapters']):
if adapter['enable']:
index -= 1
if index == -1:
real_index = i
break
new_cfg = {
'adapter': adapter_inst.name,
'enable': True,
**config
}
self.ap.platform_cfg.data['platform-adapters'][real_index] = new_cfg
await self.ap.platform_cfg.dump_config()
async def send(self, event: platform_events.MessageEvent, msg: platform_message.MessageChain, adapter: msadapter.MessageSourceAdapter):
if self.ap.platform_cfg.data['at-sender'] and isinstance(event, platform_events.GroupMessage):

View File

@@ -55,6 +55,11 @@ class DiscordMessageConverter(adapter.MessageConverter):
image_files.append(discord.File(fp=image_bytes, filename=f"{uuid.uuid4()}.png"))
elif isinstance(ele, platform_message.Plain):
text_string += ele.text
elif isinstance(ele, platform_message.Forward):
for node in ele.node_list:
text_string, image_files = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += text_string
image_files.extend(image_files)
return text_string, image_files

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
import gewechat_client
import typing
import asyncio
import traceback
import time
import re
import base64
import uuid
import json
import os
import copy
import datetime
import threading
import quart
import aiohttp
from .. import adapter
from ...pipeline.longtext.strategies import forward
from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...utils import image
class GewechatMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain
) -> list[dict]:
content_list = []
for component in message_chain:
if isinstance(component, platform_message.At):
content_list.append({"type": "at", "target": component.target})
elif isinstance(component, platform_message.Plain):
content_list.append({"type": "text", "content": component.text})
elif isinstance(component, platform_message.Image):
# content_list.append({"type": "image", "image_id": component.image_id})
pass
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
return content_list
@staticmethod
async def target2yiri(
message: dict,
bot_account_id: str
) -> platform_message.MessageChain:
if message["Data"]["MsgType"] == 1:
# 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉
regex = re.compile(r"^wxid_.*:")
line_split = message["Data"]["Content"]["string"].split("\n")
if len(line_split) > 0 and regex.match(line_split[0]):
message["Data"]["Content"]["string"] = "\n".join(line_split[1:])
at_string = f'@{bot_account_id}'
content_list = []
if at_string in message["Data"]["Content"]["string"]:
content_list.append(platform_message.At(target=bot_account_id))
content_list.append(platform_message.Plain(message["Data"]["Content"]["string"].replace(at_string, "", 1)))
else:
content_list = [platform_message.Plain(message["Data"]["Content"]["string"])]
return platform_message.MessageChain(content_list)
elif message["Data"]["MsgType"] == 3:
image_base64 = message["Data"]["ImgBuf"]["buffer"]
return platform_message.MessageChain(
[platform_message.Image(base64=f"data:image/jpeg;base64,{image_base64}")]
)
class GewechatEventConverter(adapter.EventConverter):
@staticmethod
async def yiri2target(
event: platform_events.MessageEvent
) -> dict:
pass
@staticmethod
async def target2yiri(
event: dict,
bot_account_id: str
) -> platform_events.MessageEvent:
message_chain = await GewechatMessageConverter.target2yiri(copy.deepcopy(event), bot_account_id)
if not message_chain:
return None
if '@chatroom' in event["Data"]["FromUserName"]["string"]:
# 找出开头的 wxid_ 字符串,以:结尾
sender_wxid = event["Data"]["Content"]["string"].split(":")[0]
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_wxid,
member_name=event["Data"]["FromUserName"]["string"],
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=event["Data"]["FromUserName"]["string"],
name=event["Data"]["FromUserName"]["string"],
permission=platform_entities.Permission.Member,
),
special_title="",
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event["Data"]["CreateTime"],
source_platform_object=event,
)
elif 'wxid_' in event["Data"]["FromUserName"]["string"]:
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event["Data"]["FromUserName"]["string"],
nickname=event["Data"]["FromUserName"]["string"],
remark='',
),
message_chain=message_chain,
time=event["Data"]["CreateTime"],
source_platform_object=event,
)
@adapter.adapter_class("gewechat")
class GewechatMessageSourceAdapter(adapter.MessageSourceAdapter):
bot: gewechat_client.GewechatClient
quart_app: quart.Quart
bot_account_id: str
config: dict
ap: app.Application
message_converter: GewechatMessageConverter = GewechatMessageConverter()
event_converter: GewechatEventConverter = GewechatEventConverter()
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
self.config = config
self.ap = ap
self.quart_app = quart.Quart(__name__)
@self.quart_app.route('/gewechat/callback', methods=['POST'])
async def gewechat_callback():
data = await quart.request.json
# print(json.dumps(data, indent=4, ensure_ascii=False))
if 'testMsg' in data:
return 'ok'
elif 'TypeName' in data and data['TypeName'] == 'AddMsg':
try:
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
except Exception as e:
traceback.print_exc()
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return 'ok'
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain
):
pass
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False
):
content_list = await self.message_converter.yiri2target(message)
ats = [item["target"] for item in content_list if item["type"] == "at"]
for msg in content_list:
if msg["type"] == "text":
if ats:
member_info = self.bot.get_chatroom_member_detail(
self.config["app_id"],
message_source.source_platform_object["Data"]["FromUserName"]["string"],
ats[::-1]
)["data"]
for member in member_info:
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
self.bot.post_text(
app_id=self.config["app_id"],
to_wxid=message_source.source_platform_object["Data"]["FromUserName"]["string"],
content=msg["content"],
ats=','.join(ats)
)
async def is_muted(self, group_id: int) -> bool:
pass
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None]
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None]
):
pass
async def run_async(self):
if not self.config["token"]:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.config['gewechat_url']}/v2/api/tools/getTokenId",
json={"app_id": self.config["app_id"]}
) as response:
if response.status != 200:
raise Exception(f"获取gewechat token失败: {await response.text()}")
self.config["token"] = (await response.json())["data"]
self.bot = gewechat_client.GewechatClient(
f"{self.config['gewechat_url']}/v2/api",
self.config["token"]
)
app_id, error_msg = self.bot.login(self.config["app_id"])
if error_msg:
raise Exception(f"Gewechat 登录失败: {error_msg}")
self.config["app_id"] = app_id
self.ap.logger.info(f"Gewechat 登录成功app_id: {app_id}")
await self.ap.platform_mgr.write_back_config(self, self.config)
# 获取 nickname
profile = self.bot.get_profile(self.config["app_id"])
self.bot_account_id = profile["data"]["nickName"]
def thread_set_callback():
time.sleep(3)
ret = self.bot.set_callback(self.config["token"], self.config["callback_url"])
print('设置 Gewechat 回调:', ret)
threading.Thread(target=thread_set_callback).start()
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await self.quart_app.run_task(
host='0.0.0.0',
port=self.config["port"],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
pass

View File

@@ -90,6 +90,9 @@ class LarkMessageConverter(adapter.MessageConverter):
]
)
pending_paragraph = []
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
message_elements.extend(await LarkMessageConverter.yiri2target(node.message_chain, api_client))
if pending_paragraph:
message_elements.append(pending_paragraph)

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
import typing
import asyncio
import traceback
import time
import datetime
import aiocqhttp
import aiohttp
from pkg.platform.adapter import MessageSourceAdapter
from pkg.platform.types import events as platform_events, message as platform_message
from pkg.core import app
from .. import adapter
from ...pipeline.longtext.strategies import forward
from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from libs.qq_official_api.api import QQOfficialClient
from libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
class QQOfficialMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
content_list = []
#只实现了发文字
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append({
"type":"text",
"content":msg.text,
})
return content_list
@staticmethod
async def target2yiri(message:str,message_id:str,pic_url:str,content_type):
yiri_msg_list = []
yiri_msg_list.append(
platform_message.Source(id=message_id,time=datetime.datetime.now())
)
if pic_url is not None:
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url,content_type=content_type)
yiri_msg_list.append(
platform_message.Image(base64=base64_url)
)
message = ''
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
class QQOfficialEventConverter(adapter.EventConverter):
@staticmethod
async def yiri2target(event:platform_events.MessageEvent) -> QQOfficialEvent:
return event.source_platform_object
@staticmethod
async def target2yiri(event:QQOfficialEvent):
"""
QQ官方消息转换为LB对象
"""
yiri_chain = await QQOfficialMessageConverter.target2yiri(
message=event.content,message_id=event.d_id,pic_url=event.attachments,content_type=event.content_type
)
if event.t == 'C2C_MESSAGE_CREATE':
friend = platform_entities.Friend(
id = event.user_openid,
nickname = event.t,
remark = "",
)
return platform_events.FriendMessage(
sender = friend,message_chain = yiri_chain,time = event.timestamp,
source_platform_object=event
)
if event.t == 'DIRECT_MESSAGE_CREATE':
friend = platform_entities.Friend(
id = event.guild_id,
nickname = event.t,
remark = "",
)
return platform_events.FriendMessage(
sender = friend,message_chain = yiri_chain,
source_platform_object=event
)
if event.t == 'GROUP_AT_MESSAGE_CREATE':
yiri_chain.insert(0, platform_message.At(target="justbot"))
sender = platform_entities.GroupMember(
id = event.group_openid,
member_name= event.t,
permission= 'MEMBER',
group = platform_entities.Group(
id = 0,
name = 'MEMBER',
permission= platform_entities.Permission.Member
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0
)
time = event.timestamp
return platform_events.GroupMessage(
sender = sender,
message_chain=yiri_chain,
time = time,
source_platform_object=event
)
if event.t =='AT_MESSAGE_CREATE':
yiri_chain.insert(0, platform_message.At(target="justbot"))
sender = platform_entities.GroupMember(
id = event.channel_id,
member_name=event.t,
permission= 'MEMBER',
group = platform_entities.Group(
id = 0,
name = 'MEMBER',
permission=platform_entities.Permission.Member
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0
)
time = event.timestamp,
return platform_events.GroupMessage(
sender =sender,
message_chain = yiri_chain,
time = time,
source_platform_object=event
)
@adapter.adapter_class("qqofficial")
class QQOfficialAdapter(adapter.MessageSourceAdapter):
bot:QQOfficialClient
ap:app.Application
config:dict
bot_account_id:str
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config:dict, ap:app.Application):
self.config = config
self.ap = ap
required_keys = [
"appid",
"secret",
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise ParamNotEnoughError("QQ官方机器人缺少相关配置项请查看文档或联系管理员")
self.bot = QQOfficialClient(
app_id=config["appid"],
secret=config["secret"],
token=config["token"],
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
qq_official_event = await QQOfficialEventConverter.yiri2target(
message_source,
)
content_list = await QQOfficialMessageConverter.yiri2target(message)
#私聊消息
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_private_text_msg(qq_official_event.user_openid,content['content'],qq_official_event.d_id)
#群聊消息
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_group_text_msg(qq_official_event.group_openid,content['content'],qq_official_event.d_id)
#频道群聊
if qq_official_event.t == 'AT_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_channle_group_text_msg(qq_official_event.channel_id,content['content'],qq_official_event.d_id)
#频道私聊
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
for content in content_list:
if content["type"] == 'text':
await self.bot.send_channle_private_text_msg(qq_official_event.guild_id,content['content'],qq_official_event.d_id)
async def send_message(
self, target_type: str, target_id: str, message: platform_message.MessageChain
):
pass
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, adapter.MessageSourceAdapter], None
],
):
async def on_message(event:QQOfficialEvent):
self.bot_account_id = "justbot"
try:
return await callback(
await self.event_converter.target2yiri(event),self
)
except:
traceback.print_exc()
if event_type == platform_events.FriendMessage:
self.bot.on_message("DIRECT_MESSAGE_CREATE")(on_message)
self.bot.on_message("C2C_MESSAGE_CREATE")(on_message)
elif event_type == platform_events.GroupMessage:
self.bot.on_message("GROUP_AT_MESSAGE_CREATE")(on_message)
self.bot.on_message("AT_MESSAGE_CREATE")(on_message)
async def run_async(self):
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await self.bot.run_task(
host='0.0.0.0',
port=self.config["port"],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False
def unregister_listener(
self,
event_type: type,
callback: typing.Callable[[platform_events.Event, MessageSourceAdapter], None],
):
return super().unregister_listener(event_type, callback)

View File

@@ -29,17 +29,6 @@ class WecomMessageConverter(adapter.MessageConverter):
):
content_list = []
[
{
"type": "text",
"content": "text",
},
{
"type": "image",
"media_id": "media_id",
}
]
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append({
@@ -83,7 +72,7 @@ class WecomMessageConverter(adapter.MessageConverter):
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
yiri_msg_list.append(platform_message.Image(base64=f"data:image/{image_format};base64,{image_base64}"))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
@@ -208,7 +197,7 @@ class WecomeAdapter(adapter.MessageSourceAdapter):
await self.bot.send_private_msg(Wecom_event.user_id, Wecom_event.agent_id, content["content"])
elif content["type"] == "image":
await self.bot.send_image(Wecom_event.user_id, Wecom_event.agent_id, content["media_id"])
async def send_message(
self, target_type: str, target_id: str, message: platform_message.MessageChain
):

View File

@@ -785,10 +785,20 @@ class ForwardMessageNode(pydantic.BaseModel):
)
class ForwardMessageDiaplay(pydantic.BaseModel):
title: str = "群聊的聊天记录"
brief: str = "[聊天记录]"
source: str = "聊天记录"
preview: typing.List[str] = []
summary: str = "查看x条转发消息"
class Forward(MessageComponent):
"""合并转发。"""
type: str = "Forward"
"""消息组件类型。"""
display: ForwardMessageDiaplay
"""显示信息"""
node_list: typing.List[ForwardMessageNode]
"""转发消息节点列表。"""
def __init__(self, *args, **kwargs):

View File

@@ -1,4 +1,4 @@
semantic_version = "v3.4.5.1"
semantic_version = "v3.4.6"
debug_mode = False

View File

@@ -6,6 +6,7 @@ import ssl
import aiohttp
import PIL.Image
import httpx
async def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:
"""
@@ -30,7 +31,19 @@ async def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:
image_base64 = base64.b64encode(image_data).decode('utf-8')
return image_base64, image_format
async def get_qq_official_image_base64(pic_url:str,content_type:str) -> tuple[str,str]:
"""
下载QQ官方图片
并且转换为base64格式
"""
async with httpx.AsyncClient() as client:
response = await client.get(pic_url)
response.raise_for_status() # 确保请求成功
image_data = response.content
base64_data = base64.b64encode(image_data).decode('utf-8')
return f"data:{content_type};base64,{base64_data}"
def get_qq_image_downloadable_url(image_url: str) -> tuple[str, dict]:

View File

@@ -27,6 +27,8 @@ pyjwt
pycryptodome
lark-oapi
discord.py
cryptography
gewechat-client
# indirect
taskgroup==0.0.0a4

View File

@@ -0,0 +1 @@
[]

1
res/instance_id.json Normal file
View File

@@ -0,0 +1 @@
{"host_id": "host_9b4a220d-3bb6-42fc-aec3-41188ce0a41c", "instance_id": "instance_61d8f262-b98a-4165-8e77-85fb6262529e", "instance_create_ts": 1736824678}

View File

@@ -25,6 +25,14 @@
"direct_message"
]
},
{
"adapter": "qqofficial",
"enable": false,
"appid": "1234567890",
"secret": "xxxxxxx",
"port": 2284,
"token": "abcdefg"
},
{
"adapter": "wecom",
"enable": false,
@@ -48,6 +56,15 @@
"enable": false,
"client_id": "1234567890",
"token": "XXXXXXXXXX"
},
{
"adapter": "gewechat",
"enable": false,
"gewechat_url": "http://your-gewechat-server:2531",
"port": 2286,
"callback_url": "http://your-callback-url:2286/gewechat/callback",
"app_id": "",
"token": ""
}
],
"track-function-calls": true,
@@ -58,7 +75,7 @@
"max": 0
},
"long-text-process": {
"threshold": 256,
"threshold": 2560,
"strategy": "forward",
"font-path": ""
},

View File

@@ -83,7 +83,7 @@
}
},
{
"title": "qq-botpy 适配器",
"title": "qq-botpy 适配器WebSocket",
"description": "用于接入 QQ 官方机器人 API",
"properties": {
"adapter": {
@@ -122,6 +122,47 @@
}
}
},
{
"title": "QQ 官方适配器WebHook",
"description": "用于接入 QQ 官方机器人 API",
"properties": {
"adapter": {
"type": "string",
"const": "qqofficial"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"appid": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的appid"
},
"secret": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的secret"
},
"port": {
"type": "integer",
"default": 2284,
"description": "监听的端口"
},
"token": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的token"
}
}
},
{
"title": "企业微信适配器",
"description": "用于接入企业微信",
@@ -244,6 +285,52 @@
"description": "Discord 的 token"
}
}
},
{
"title": "gewechat 适配器",
"description": "用于接入个人微信",
"properties": {
"adapter": {
"type": "string",
"const": "gewechat"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"gewechat_url": {
"type": "string",
"default": "",
"description": "gewechat 的 url"
},
"port": {
"type": "integer",
"default": 2286,
"description": "gewechat 的端口"
},
"callback_url": {
"type": "string",
"default": "",
"description": "回调地址LangBot主机相对于gewechat服务器的地址"
},
"app_id": {
"type": "string",
"default": "",
"description": "gewechat 的 app_id"
},
"token": {
"type": "string",
"default": "",
"description": "gewechat 的 token"
}
}
}
]
}

View File

@@ -0,0 +1,181 @@
<template>
<div class="plugin-card">
<div class="plugin-card-header">
<div class="plugin-id">
<div class="plugin-card-author">{{ plugin.author }} /</div>
<div class="plugin-card-title">{{ plugin.name }}</div>
</div>
<div class="plugin-card-badges">
<v-icon class="plugin-github-source" icon="mdi-github" v-if="plugin.repository != ''"
@click="openGithubSource"></v-icon>
</div>
</div>
<div class="plugin-card-description" >{{ plugin.description }}</div>
<div class="plugin-card-brief-info">
<div class="plugin-card-brief-info-item">
<v-icon id="plugin-stars-icon" icon="mdi-star" />
<div id="plugin-stars-count">{{ plugin.stars }}</div>
</div>
<v-btn color="primary" @click="installPlugin" density="compact">安装</v-btn>
</div>
</div>
</template>
<script setup>
const props = defineProps({
plugin: {
type: Object,
required: true
},
});
const emit = defineEmits(['install']);
const openGithubSource = () => {
window.open("https://"+props.plugin.repository, '_blank');
}
const installPlugin = () => {
emit('install', props.plugin);
}
</script>
<style scoped>
.plugin-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 0.8rem;
padding-left: 1rem;
margin: 1rem 0;
background-color: white;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 10rem;
}
.plugin-card-header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.plugin-card-author {
font-size: 0.8rem;
color: #666;
font-weight: 500;
user-select: none;
}
.plugin-card-title {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
}
.plugin-card-description {
font-size: 0.7rem;
color: #666;
font-weight: 500;
margin-top: -1rem;
height: 2rem;
/* 超出部分自动换行,最多两行 */
text-overflow: ellipsis;
overflow-y: hidden;
white-space: wrap;
user-select: none;
}
.plugin-card-badges {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.plugin-github-source {
cursor: pointer;
color: #222;
font-size: 1.3rem;
}
.plugin-disabled {
font-size: 0.7rem;
font-weight: 500;
height: 1.3rem;
padding-inline: 0.4rem;
user-select: none;
}
.plugin-card-brief-info {
display: flex;
flex-direction: row;
justify-content: space-between;
/* background-color: #f0f0f0; */
gap: 0.8rem;
margin-left: -0.2rem;
margin-bottom: 0rem;
}
.plugin-card-events {
display: flex;
flex-direction: row;
gap: 0.4rem;
}
.plugin-card-events-icon {
font-size: 1.8rem;
color: #666;
}
.plugin-card-events-count {
font-size: 1.2rem;
font-weight: 600;
color: #666;
}
.plugin-card-functions {
display: flex;
flex-direction: row;
gap: 0.4rem;
}
.plugin-card-functions-icon {
font-size: 1.6rem;
color: #666;
}
.plugin-card-functions-count {
font-size: 1.2rem;
font-weight: 600;
color: #666;
}
.plugin-card-brief-info-item {
display: flex;
flex-direction: row;
gap: 0.4rem;
}
#plugin-stars-icon {
color: #0073ff;
}
#plugin-stars-count {
margin-top: 0.1rem;
font-weight: 700;
color: #0073ff;
user-select: none;
}
.plugin-card-brief-info-item:hover .plugin-card-brief-info-item-icon {
color: #333;
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div id="marketplace-container">
<div id="marketplace-search-bar">
<span style="width: 14rem;">
<v-text-field id="marketplace-search-bar-search-input" variant="solo" v-model="proxy.$store.state.marketplaceParams.query" label="搜索"
density="compact" @update:model-value="updateSearch" />
</span>
<!--下拉选择排序-->
<span style="width: 10rem;">
<v-select id="marketplace-search-bar-sort-select" v-model="sort" :items="sortItems" variant="solo"
label="排序" density="compact" @update:model-value="updateSort" />
</span>
<span style="margin-left: 1rem;">
<div id="marketplace-search-bar-total-plugins-count">
{{ proxy.$store.state.marketplaceTotalPluginsCount }} 个插件
</div>
</span>
<span style="margin-left: 1rem;">
<!-- 分页 -->
<v-pagination style="width: 14rem;" v-model="proxy.$store.state.marketplaceParams.page"
:length="proxy.$store.state.marketplaceTotalPages" variant="solo" density="compact"
total-visible="4" @update:model-value="updatePage" />
</span>
</div>
<div id="marketplace-plugins-container" ref="pluginsContainer">
<div id="marketplace-plugins-container-inner">
<MarketPluginCard v-for="plugin in proxy.$store.state.marketplacePlugins" :key="plugin.id" :plugin="plugin" @install="installPlugin" />
</div>
</div>
</div>
</template>
<script setup>
import MarketPluginCard from './MarketPluginCard.vue'
import { ref, getCurrentInstance, onMounted } from 'vue'
import { inject } from "vue";
const snackbar = inject('snackbar');
const { proxy } = getCurrentInstance()
const pluginsContainer = ref(null)
const sortItems = ref([
'最近新增',
'最多星标',
'最近更新',
])
const sortParams = ref({
'最近新增': {
sort_by: 'created_at',
sort_order: 'DESC',
},
'最多星标': {
sort_by: 'stars',
sort_order: 'DESC',
},
'最近更新': {
sort_by: 'pushed_at',
sort_order: 'DESC',
}
})
const sort = ref(sortItems.value[0])
proxy.$store.state.marketplaceParams.sort_by = sortParams.value[sort.value].sort_by
proxy.$store.state.marketplaceParams.sort_order = sortParams.value[sort.value].sort_order
const updateSort = (value) => {
console.log(value)
proxy.$store.state.marketplaceParams.sort_by = sortParams.value[value].sort_by
proxy.$store.state.marketplaceParams.sort_order = sortParams.value[value].sort_order
proxy.$store.state.marketplaceParams.page = 1
console.log(proxy.$store.state.marketplaceParams)
fetchMarketplacePlugins()
}
const updatePage = (value) => {
proxy.$store.state.marketplaceParams.page = value
fetchMarketplacePlugins()
}
const updateSearch = (value) => {
console.log(value)
proxy.$store.state.marketplaceParams.query = value
proxy.$store.state.marketplaceParams.page = 1
fetchMarketplacePlugins()
}
const calculatePluginsPerPage = () => {
if (!pluginsContainer.value) return 10
const containerWidth = pluginsContainer.value.clientWidth
const containerHeight = pluginsContainer.value.clientHeight
console.log(containerWidth, containerHeight)
// 每个卡片宽度18rem + gap 16px
const cardWidth = 18 * 16 + 16 // rem转px
// 每个卡片高度9rem + gap 16px
const cardHeight = 9 * 16 + 16
// 计算每行可以放几个卡片
const cardsPerRow = Math.floor(containerWidth / cardWidth)
// 计算每行可以放几行
const rows = Math.floor(containerHeight / cardHeight)
// 计算每页总数
const perPage = cardsPerRow * rows
proxy.$store.state.marketplaceParams.per_page = perPage > 0 ? perPage : 10
}
const fetchMarketplacePlugins = async () => {
calculatePluginsPerPage()
proxy.$axios.post('https://space.langbot.app/api/v1/market/plugins', {
query: proxy.$store.state.marketplaceParams.query,
sort_by: proxy.$store.state.marketplaceParams.sort_by,
sort_order: proxy.$store.state.marketplaceParams.sort_order,
page: proxy.$store.state.marketplaceParams.page,
page_size: proxy.$store.state.marketplaceParams.per_page,
}).then(response => {
console.log(response.data)
if (response.data.code != 0) {
snackbar.error(response.data.msg)
return
}
// 解析出 name 和 author
response.data.data.plugins.forEach(plugin => {
plugin.name = plugin.repository.split('/')[2]
plugin.author = plugin.repository.split('/')[1]
})
proxy.$store.state.marketplacePlugins = response.data.data.plugins
proxy.$store.state.marketplaceTotalPluginsCount = response.data.data.total
let totalPages = Math.floor(response.data.data.total / proxy.$store.state.marketplaceParams.per_page)
if (response.data.data.total % proxy.$store.state.marketplaceParams.per_page != 0) {
totalPages += 1
}
proxy.$store.state.marketplaceTotalPages = totalPages
}).catch(error => {
snackbar.error(error)
})
}
onMounted(() => {
calculatePluginsPerPage()
fetchMarketplacePlugins()
// 监听窗口大小变化
window.addEventListener('resize', () => {
calculatePluginsPerPage()
fetchMarketplacePlugins()
})
})
const emit = defineEmits(['installPlugin'])
const installPlugin = (plugin) => {
emit('installPlugin', plugin.repository)
}
</script>
<style scoped>
#marketplace-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
#marketplace-search-bar {
display: flex;
flex-direction: row;
margin-top: 1rem;
padding-right: 1rem;
gap: 1rem;
width: 100%;
}
#marketplace-search-bar-search-input {
position: relative;
left: 1rem;
width: 10rem;
}
#marketplace-search-bar-total-plugins-count {
font-size: 1.1rem;
font-weight: 500;
margin-top: 0.5rem;
color: #666;
user-select: none;
}
.plugin-card {
width: 18rem;
height: 9rem;
}
#marketplace-plugins-container {
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-wrap: wrap;
gap: 16px;
margin-inline: 0rem;
width: 100%;
height: calc(100vh - 16rem);
overflow-y: auto;
}
#marketplace-plugins-container-inner {
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-wrap: wrap;
gap: 16px;
}
</style>

View File

@@ -2,6 +2,10 @@
<PageTitle title="插件" @refresh="refresh" />
<v-card id="plugins-toolbar">
<div id="view-btns">
<v-btn-toggle id="plugins-view-toggle" color="primary" v-model="proxy.$store.state.pluginsView" mandatory density="compact">
<v-btn class="plugins-view-toggle-btn" value="installed" density="compact">已安装</v-btn>
<v-btn class="plugins-view-toggle-btn" value="market" density="compact">插件市场</v-btn>
</v-btn-toggle>
</div>
<div id="operation-btns">
<v-tooltip text="设置插件优先级" location="top">
@@ -78,17 +82,21 @@
</v-btn>
</div>
</v-card>
<div class="plugins-container">
<div class="plugins-container" v-if="proxy.$store.state.pluginsView == 'installed'">
<v-alert id="no-plugins-alert" v-if="plugins.length == 0" color="warning" icon="$warning" title="暂无插件" text="暂无已安装的插件,请安装插件" density="compact" style="margin-inline: 1rem;"></v-alert>
<PluginCard class="plugin-card" v-if="plugins.length > 0" v-for="plugin in plugins" :key="plugin.name" :plugin="plugin"
@toggle="togglePlugin" @update="updatePlugin" @remove="removePlugin" />
</div>
<div class="plugins-container" v-if="proxy.$store.state.pluginsView == 'market'">
<Marketplace @installPlugin="installMarketplacePlugin" />
</div>
</template>
<script setup>
import PageTitle from '@/components/PageTitle.vue'
import PluginCard from '@/components/PluginCard.vue'
import Marketplace from '@/components/Marketplace.vue'
import draggable from 'vuedraggable'
@@ -154,6 +162,12 @@ const removePlugin = (plugin) => {
})
}
const installMarketplacePlugin = (repository) => {
installDialogSource.value = 'https://'+repository
isInstallDialogActive.value = true
}
const installPlugin = () => {
if (installDialogSource.value == '' || installDialogSource.value.trim() == '') {
@@ -224,6 +238,11 @@ const installDialogSource = ref('')
margin-left: 1rem;
}
#plugins-view-toggle {
margin: 0.5rem;
box-shadow: 0 0 0 2px #dddddd;
}
#operation-btns {
display: flex;
flex-direction: row;

View File

@@ -18,7 +18,18 @@ export default createStore({
tokenValid: false,
systemInitialized: true,
jwtToken: '',
}
},
pluginsView: 'installed',
marketplaceParams: {
query: '',
page: 1,
per_page: 10,
sort_by: 'pushed_at',
sort_order: 'DESC',
},
marketplacePlugins: [],
marketplaceTotalPages: 0,
marketplaceTotalPluginsCount: 0,
},
mutations: {
initializeFetch() {