Compare commits

...

2 Commits

5 changed files with 353 additions and 5 deletions

View File

@@ -18,6 +18,7 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.events as events
class RuntimeBot:
@@ -141,6 +142,56 @@ class RuntimeBot:
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
async def on_notice(
event: platform_events.NoticeEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self.logger.info(f'Notice event: {event.notice_type} {event.sub_type}')
try:
event_obj = events.NoticeReceived(
notice_type=event.notice_type,
sub_type=event.sub_type,
group_id=event.group_id,
user_id=event.user_id,
operator_id=event.operator_id,
target_id=event.target_id,
message_id=event.message_id,
duration=event.duration,
file=event.file,
honor_type=event.honor_type,
)
if hasattr(self.ap, 'plugin_connector') and self.ap.plugin_connector:
await self.ap.plugin_connector.emit_event(event_obj)
except Exception:
await self.logger.error(f'Error emitting notice event: {traceback.format_exc()}')
self.adapter.register_listener(platform_events.NoticeEvent, on_notice)
async def on_request(
event: platform_events.RequestEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
await self.logger.info(f'Request event: {event.request_type} {event.sub_type}')
try:
event_obj = events.RequestReceived(
request_type=event.request_type,
sub_type=event.sub_type,
user_id=event.user_id,
group_id=event.group_id,
comment=event.comment,
flag=event.flag,
)
if hasattr(self.ap, 'plugin_connector') and self.ap.plugin_connector:
await self.ap.plugin_connector.emit_event(event_obj)
except Exception:
await self.logger.error(f'Error emitting request event: {traceback.format_exc()}')
self.adapter.register_listener(platform_events.RequestEvent, on_request)
async def run(self):
async def exception_wrapper():
try:

View File

@@ -306,9 +306,8 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def target2yiri(event: aiocqhttp.Event, bot=None):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
if event.message_type == 'group':
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
permission = 'MEMBER'
if 'role' in event.sender:
@@ -334,6 +333,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
return converted_event
elif event.message_type == 'private':
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.sender['user_id'],
@@ -344,6 +344,57 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
time=event.time,
source_platform_object=event,
)
elif event.post_type == 'notice':
yiri_chain = platform_message.MessageChain(
[
platform_message.Source(id=-1, time=datetime.datetime.now()),
platform_message.Notice(
notice_type=event.get('notice_type', ''),
sub_type=event.get('sub_type', ''),
user_id=event.get('user_id', None),
target_id=event.get('target_id', None),
group_id=event.get('group_id', None),
operator_id=event.get('operator_id', None),
message_id=event.get('message_id', None),
duration=event.get('duration', None),
file=event.get('file', None),
honor_type=event.get('honor_type', None),
),
]
)
return platform_events.NoticeEvent(
notice_type=event.get('notice_type', ''),
sub_type=event.get('sub_type', ''),
user_id=event.get('user_id', None),
target_id=event.get('target_id', None),
group_id=event.get('group_id', None),
time=event.time,
source_platform_object=event,
)
elif event.post_type == 'request':
yiri_chain = platform_message.MessageChain(
[
platform_message.Source(id=-1, time=datetime.datetime.now()),
platform_message.Request(
request_type=event.get('request_type', ''),
sub_type=event.get('sub_type', ''),
user_id=event.get('user_id', None),
group_id=event.get('group_id', None),
comment=event.get('comment', ''),
flag=event.get('flag', ''),
),
]
)
return platform_events.RequestEvent(
request_type=event.get('request_type', ''),
sub_type=event.get('sub_type', ''),
user_id=event.get('user_id', None),
group_id=event.get('group_id', None),
comment=event.get('comment', ''),
flag=event.get('flag', ''),
time=event.time,
source_platform_object=event,
)
class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
@@ -413,12 +464,31 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
await self.logger.error(f'Error in on_message: {traceback.format_exc()}')
traceback.print_exc()
async def on_notice(event: aiocqhttp.Event):
self.bot_account_id = event.self_id
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except Exception:
await self.logger.error(f'Error in on_notice: {traceback.format_exc()}')
traceback.print_exc()
async def on_request(event: aiocqhttp.Event):
self.bot_account_id = event.self_id
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except Exception:
await self.logger.error(f'Error in on_request: {traceback.format_exc()}')
traceback.print_exc()
if event_type == platform_events.GroupMessage:
self.bot.on_message('group')(on_message)
# self.bot.on_notice()(on_message)
elif event_type == platform_events.FriendMessage:
self.bot.on_message('private')(on_message)
# self.bot.on_notice()(on_message)
elif event_type == platform_events.NoticeEvent:
self.bot.on_notice()(on_notice)
elif event_type == platform_events.RequestEvent:
self.bot.on_request()(on_request)
# print(event_type)
async def on_websocket_connection(event: aiocqhttp.Event):

View File

@@ -1,6 +1,13 @@
'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import {
useState,
useEffect,
useCallback,
useRef,
Suspense,
useMemo,
} from 'react';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -23,6 +30,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { TagsFilter } from './TagsFilter';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
import { RecommendationLists, RecommendationList } from './RecommendationLists';
interface SortOption {
value: string;
label: string;
@@ -50,6 +59,9 @@ function MarketPageContent({
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const [sortOption, setSortOption] = useState('install_count_desc');
const [recommendationLists, setRecommendationLists] = useState<
RecommendationList[]
>([]);
const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -203,6 +215,20 @@ function MarketPageContent({
}
};
// Fetch recommendation lists
useEffect(() => {
async function fetchRecommendationLists() {
try {
const response =
await getCloudServiceClientSync().getRecommendationLists();
setRecommendationLists(response.lists || []);
} catch (error) {
console.error('Failed to fetch recommendation lists:', error);
}
}
fetchRecommendationLists();
}, []);
// 搜索功能
const handleSearch = useCallback(
(query: string) => {
@@ -306,6 +332,39 @@ function MarketPageContent({
};
}, []);
// 计算所有推荐插件的 ID 集合
const recommendedPluginIds = useMemo(() => {
const ids = new Set<string>();
recommendationLists.forEach((list) => {
list.plugins.forEach((plugin) => {
ids.add(`${plugin.author} / ${plugin.name}`);
});
});
return ids;
}, [recommendationLists]);
// 过滤掉已在推荐列表中展示的插件
// 仅在显示推荐列表的条件下(无搜索、无筛选、第一页或后续页的累积数据中)进行过滤
// 注意:如果用户翻页,我们希望一直保持去重,否则推荐过的插件会在第二页出现
// 但是推荐列表只在第一页且无筛选时显示。
// 如果用户进行了筛选/搜索,推荐列表不显示,此时不需要去重。
const visiblePlugins = useMemo(() => {
const showRecommendations =
!searchQuery && componentFilter === 'all' && selectedTags.length === 0;
if (!showRecommendations) {
return plugins;
}
return plugins.filter((p) => !recommendedPluginIds.has(p.pluginId));
}, [
plugins,
recommendedPluginIds,
searchQuery,
componentFilter,
selectedTags,
]);
// 加载更多
const loadMore = useCallback(() => {
if (!isLoadingMore && hasMore) {
@@ -494,6 +553,20 @@ function MarketPageContent({
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-3 sm:px-4"
>
{/* Recommendation Lists */}
{!searchQuery &&
componentFilter === 'all' &&
selectedTags.length === 0 &&
currentPage === 1 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingSpinner text={t('market.loading')} />
@@ -507,7 +580,7 @@ function MarketPageContent({
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4">
{plugins.map((plugin) => (
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}

View File

@@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
export interface RecommendationList {
uuid: string;
label: I18nObject;
sort_order: number;
plugins: PluginV4[];
}
const PAGE_SIZE = 4; // plugins per page in a recommendation row
function pluginToVO(
plugin: PluginV4,
t: (key: string) => string,
): PluginMarketCardVO {
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
pluginName: plugin.name,
label: extractI18nObject(plugin.label),
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
iconURL: getCloudServiceClientSync().getPluginIconURL(
plugin.author,
plugin.name,
),
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
});
}
function RecommendationListRow({
list,
tagNames,
onInstall,
}: {
list: RecommendationList;
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
}) {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const plugins = list.plugins || [];
const totalPages = Math.ceil(plugins.length / PAGE_SIZE);
const start = page * PAGE_SIZE;
const visiblePlugins = plugins.slice(start, start + PAGE_SIZE);
if (plugins.length === 0) return null;
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-yellow-500" />
<h3 className="font-semibold text-base">
{extractI18nObject(list.label)}
</h3>
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="h-7 w-7 p-0"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground px-1">
{page + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="h-7 w-7 p-0"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={pluginToVO(plugin, t)}
tagNames={tagNames}
onInstall={onInstall}
/>
))}
</div>
{totalPages > 1 && <div className="border-b border-border mt-6" />}
</div>
);
}
export function RecommendationLists({
lists,
tagNames,
onInstall,
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
}) {
if (!lists || lists.length === 0) return null;
return (
<div className="mt-6">
{lists.map((list) => (
<RecommendationListRow
key={list.uuid}
list={list}
tagNames={tagNames}
onInstall={onInstall}
/>
))}
<div className="border-b border-border mb-6" />
</div>
);
}

View File

@@ -3,6 +3,8 @@ import {
ApiRespMarketplacePluginDetail,
ApiRespMarketplacePlugins,
} from '@/app/infra/entities/api';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { I18nObject } from '@/app/infra/entities/common';
/**
* 云服务客户端
@@ -98,6 +100,19 @@ export class CloudServiceClient extends BaseHttpClient {
public getAllTags(): Promise<{ tags: PluginTag[] }> {
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
}
public getRecommendationLists(): Promise<{ lists: RecommendationList[] }> {
return this.get<{ lists: RecommendationList[] }>(
'/api/v1/marketplace/recommendation-lists',
);
}
}
export interface RecommendationList {
uuid: string;
label: I18nObject;
sort_order: number;
plugins: PluginV4[];
}
export interface PluginTag {