Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ MTProto 服务的 `host`/`port` 是实际监听地址;Bridge 的 `serverHost`/
token: your-qqnt-bridge-token # 省略时读取环境变量 QQNT_BRIDGE_TOKEN
```

### 图片存储

在 bridge 配置中启用 `imageStorage.enabled`,服务器会在接收群聊、私聊和图文多图消息后主动保存图片,无需客户端在线或打开会话。默认关闭,不保存视频、头像和普通附件,也不回填历史消息;未保存且已过期的图片无法恢复。

```yaml
imageStorage:
enabled: true
path: data/bridge-images
```

图片存储查询和删除需要先在 WebUI 的「Your account」页面登录,任何已登录会话都可以管理,无需单独配置管理员。未启用 SSO 登录服务、未登录或令牌失效时拒绝管理请求;这不影响新图自动保存、Telegram 账号读取自己的副本及匿名账号发现。

原图及平台提供的独立预览图分别保存,按各自的格式和字节范围通过现有 MTProto 媒体下载读取,不公开新的 HTTP 文件地址。图片数仅计算原图,容量包含原图和预览图的实际文件字节数,删除会同时清理两种副本。关闭开关只停止新下载,已有副本仍可读取和管理。下载失败不会阻断消息接收,待处理和失败的新图任务会在账号重新连接或服务重启后重试。

WebUI 的「图片存储」页面可按账号、会话及消息日期筛选,以 UTC 自然日统计图片数和实际文件字节数,按会话或日期分页汇总。删除仅清理指定范围内的服务端副本,不删除平台消息或原图;同一消息重复推送不会重新生成主动删除的副本。删除失败会保留可重试记录。数据库与 `imageStorage.path` 目录应一起备份,修改目录不会自动迁移旧文件。

### Linux 生产部署

```sh
Expand Down
3 changes: 3 additions & 0 deletions app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
name: '@mtproto-relay/bridge'
config:
serverHost: 127.0.0.1
imageStorage:
enabled: false
path: data/bridge-images
groupFilesMiniApp:
enabled: true
path: /group-files
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Page0 from './accounts.js'
import Page1 from './stickers.js'
import Page2 from './bots.js'
import StoragePage from './storage.js'
import './style.css'
import type { ClientModule } from 'cordis-webui-react/client'
export default {
Expand All @@ -26,5 +27,12 @@ export default {
group: 'Workspace',
component: Page2,
},
{
path: '/image-storage',
title: 'Image storage',
icon: '▣',
group: 'Workspace',
component: StoragePage,
},
],
} satisfies ClientModule
297 changes: 295 additions & 2 deletions packages/bridge/client/pages.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import {
act,
cleanup,
fireEvent,
render,
waitFor,
within,
} from '@testing-library/react'
import type { ReactElement } from 'react'
import { ConnectionContext } from 'cordis-webui-react/client'
import { setSessionToken } from 'cordis-webui-react/session'
import type {
ImageStorageResult,
BotDashboardData,
CrossGramServerConfig,
PlatformAccountDashboardData,
Expand All @@ -11,6 +20,7 @@ import type {
import AccountsPage from './accounts.js'
import BotsPage from './bots.js'
import StickersPage from './stickers.js'
import StoragePage from './storage.js'

/**
* Minimal stand-in for the WebSocket connection: `useChannel` subscribes through
Expand Down Expand Up @@ -41,7 +51,13 @@ function fakeConnection(entryId: string, value: unknown, methods: string[]) {
}),
subscribe: () => () => {},
getSnapshot: () => state,
rpc: vi.fn(async () => {}),
rpc: vi.fn(
async (
_entryId: string,
_method: string,
_args: unknown[],
): Promise<unknown> => undefined,
),
push(next: unknown) {
current = { value: next, ready: true }
for (const listen of listeners) listen()
Expand All @@ -60,6 +76,7 @@ function mount(connection: FakeConnection, page: ReactElement) {

afterEach(() => {
cleanup()
setSessionToken(null)
vi.restoreAllMocks()
})

Expand Down Expand Up @@ -327,3 +344,279 @@ describe('sticker collections page', () => {
expect(container.textContent).toContain('No matching collections')
})
})

describe('image storage page', () => {
function storageResult(
title = 'Stored group',
count = 1,
bytes = 1024,
): ImageStorageResult {
return {
count,
bytes,
pendingCount: 0,
groups: bytes
? [
{
platformSessionId: 's1',
conversationId: 'group-1',
conversationTitle: title,
count,
bytes,
},
]
: [],
totalGroups: 26,
page: 1,
pageSize: 25,
}
}

function storageConnection() {
return fakeConnection(
'page',
{
imageStorageEnabled: false,
stickerAccounts: [
{ platformSessionId: 's1', displayName: 'Main QQ', userId: '42' },
],
},
['getImageStorage', 'deleteImageStorage'],
)
}

it('commits conversation text on blur, resets pagination, and ignores superseded filter responses', async () => {
setSessionToken('member')
const connection = storageConnection()
connection.rpc.mockResolvedValue(storageResult())
const { container, getByRole, getByLabelText } = mount(
connection,
<StoragePage entryId="page" path="/image-storage" />,
)
await waitFor(() => expect(container.textContent).toContain('Stored group'))
fireEvent.click(getByRole('button', { name: 'Next page' }))
await waitFor(() => expect(container.textContent).toContain('Page 2 of 2'))
fireEvent.change(getByLabelText('Account'), { target: { value: 's1' } })
await waitFor(() => expect(container.textContent).toContain('Page 1 of 2'))
expect(connection.rpc).toHaveBeenLastCalledWith('page', 'getImageStorage', [
{
platformSessionId: 's1',
groupBy: 'conversation',
page: 1,
pageSize: 25,
},
'member',
])

const first = Promise.withResolvers<ImageStorageResult>(),
latest = Promise.withResolvers<ImageStorageResult>(),
input = getByLabelText(
'Conversation ID (group or private)',
) as HTMLInputElement,
beforeTyping = connection.rpc.mock.calls.length
connection.rpc
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(latest.promise)
fireEvent.change(input, { target: { value: 'old' } })
expect(input.disabled).toBe(false)
expect(connection.rpc).toHaveBeenCalledTimes(beforeTyping)
fireEvent.blur(input)
expect(connection.rpc).toHaveBeenLastCalledWith('page', 'getImageStorage', [
{
platformSessionId: 's1',
conversationId: 'old',
groupBy: 'conversation',
page: 1,
pageSize: 25,
},
'member',
])
expect(container.textContent).not.toContain('Stored group')
expect(input.disabled).toBe(false)
fireEvent.change(input, { target: { value: ' new ' } })
fireEvent.blur(input)
expect(connection.rpc).toHaveBeenLastCalledWith('page', 'getImageStorage', [
{
platformSessionId: 's1',
conversationId: 'new',
groupBy: 'conversation',
page: 1,
pageSize: 25,
},
'member',
])
await act(async () => latest.resolve(storageResult('Newest group', 2)))
expect(container.textContent).toContain('Newest group')
await act(async () => first.resolve(storageResult('Obsolete group', 99)))
expect(container.textContent).toContain('Newest group')
expect(container.textContent).not.toContain('Obsolete group')
})

it('clears old rows and confirmation on token switch and logout, including late responses', async () => {
setSessionToken('first-member')
const connection = storageConnection()
connection.rpc.mockResolvedValue(storageResult('First member group'))
const { container, getByRole, queryByRole } = mount(
connection,
<StoragePage entryId="page" path="/image-storage" />,
)
await waitFor(() =>
expect(container.textContent).toContain('First member group'),
)
fireEvent.click(getByRole('button', { name: 'Delete filtered copies' }))
const first = Promise.withResolvers<ImageStorageResult>(),
second = Promise.withResolvers<ImageStorageResult>()
connection.rpc
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
fireEvent.click(getByRole('button', { name: 'Refresh' }))
act(() => {
setSessionToken('second-member')
window.dispatchEvent(new Event('focus'))
})
expect(queryByRole('dialog')).toBeNull()
expect(container.textContent).not.toContain('First member group')
expect(connection.rpc).toHaveBeenLastCalledWith('page', 'getImageStorage', [
{ groupBy: 'conversation', page: 1, pageSize: 25 },
'second-member',
])
await act(async () =>
second.resolve(storageResult('Second member group', 2)),
)
fireEvent.click(getByRole('button', { name: 'Delete filtered copies' }))
await act(async () =>
first.resolve(storageResult('Late first member group', 99)),
)
expect(container.textContent).toContain('Second member group')
expect(container.textContent).not.toContain('Late first member group')
expect(getByRole('dialog').textContent).toContain('2 images')

const late = Promise.withResolvers<ImageStorageResult>()
connection.rpc
.mockReturnValueOnce(late.promise)
.mockRejectedValueOnce(new Error('Sign in required'))
fireEvent.click(getByRole('button', { name: 'Refresh' }))
act(() => {
setSessionToken(null)
window.dispatchEvent(new Event('storage'))
})
expect(queryByRole('dialog')).toBeNull()
expect(container.textContent).not.toContain('Second member group')
await waitFor(() =>
expect(getByRole('alert').textContent).toContain('Sign in required'),
)
await act(async () =>
late.resolve(storageResult('Late second member group', 88)),
)
expect(queryByRole('dialog')).toBeNull()
expect(queryByRole('button', { name: 'Delete filtered copies' })).toBeNull()
expect(container.textContent).not.toContain('Late second member group')
expect(connection.rpc).toHaveBeenLastCalledWith('page', 'getImageStorage', [
{ groupBy: 'conversation', page: 1, pageSize: 25 },
undefined,
])
})

it('does not leak a late deletion error after switching away from and back to a token', async () => {
setSessionToken('member')
const connection = storageConnection()
connection.rpc.mockResolvedValue(storageResult())
const { container, getByRole, queryByRole } = mount(
connection,
<StoragePage entryId="page" path="/image-storage" />,
)
await waitFor(() => expect(container.textContent).toContain('Stored group'))
fireEvent.click(getByRole('button', { name: 'Delete filtered copies' }))
const deletion = Promise.withResolvers<ImageStorageResult>()
connection.rpc.mockReturnValueOnce(deletion.promise)
fireEvent.click(
within(getByRole('dialog')).getByRole('button', {
name: 'Delete copies',
}),
)
act(() => {
setSessionToken('other-member')
window.dispatchEvent(new Event('storage'))
})
expect(queryByRole('dialog')).toBeNull()
await waitFor(() => expect(container.textContent).toContain('Stored group'))
act(() => {
setSessionToken('member')
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => expect(container.textContent).toContain('Stored group'))
await act(async () => deletion.reject(new Error('Obsolete deletion error')))
expect(queryByRole('alert')).toBeNull()
expect(queryByRole('dialog')).toBeNull()
expect(container.textContent).toContain('Stored group')
fireEvent.click(getByRole('button', { name: 'Delete filtered copies' }))
expect(getByRole('dialog').textContent).toContain('1 images')
})

it('allows preview-only cleanup, cancellation, error retry, and deletion with the latest token', async () => {
setSessionToken('member')
const connection = storageConnection()
connection.rpc.mockResolvedValue(storageResult('Preview only', 0, 200))
const { container, getByRole, queryByRole } = mount(
connection,
<StoragePage entryId="page" path="/image-storage" />,
)
await waitFor(() => expect(container.textContent).toContain('Preview only'))
const remove = getByRole('button', {
name: 'Delete filtered copies',
}) as HTMLButtonElement
expect(remove.disabled).toBe(false)
fireEvent.click(remove)
expect(getByRole('dialog').textContent).toContain(
'0 images · 200 B (200 bytes)',
)
fireEvent.click(
within(getByRole('dialog')).getByRole('button', { name: 'Cancel' }),
)
expect(
connection.rpc.mock.calls.some(
(call) => call[1] === 'deleteImageStorage',
),
).toBe(false)

fireEvent.click(remove)
setSessionToken('fresh-member')
connection.rpc.mockRejectedValueOnce(new Error('Storage deletion failed'))
fireEvent.click(
within(getByRole('dialog')).getByRole('button', {
name: 'Delete copies',
}),
)
await waitFor(() =>
expect(
within(getByRole('dialog')).getByRole('alert').textContent,
).toContain('Storage deletion failed'),
)
expect(connection.rpc).toHaveBeenLastCalledWith(
'page',
'deleteImageStorage',
[{}, 'fresh-member'],
)
connection.rpc.mockResolvedValue(storageResult('', 0, 0))
fireEvent.click(
within(getByRole('dialog')).getByRole('button', {
name: 'Delete copies',
}),
)
await waitFor(() => expect(queryByRole('dialog')).toBeNull())
await waitFor(() =>
expect(container.textContent).toContain('No stored images in this range'),
)
expect(
(
getByRole('button', {
name: 'Delete filtered copies',
}) as HTMLButtonElement
).disabled,
).toBe(true)
expect(connection.rpc).toHaveBeenLastCalledWith('page', 'getImageStorage', [
{ groupBy: 'conversation', page: 1, pageSize: 25 },
'fresh-member',
])
})
})
Loading
Loading