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
27 changes: 26 additions & 1 deletion packages/core/src/commands/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) {
.option('all', '--all')
.action(async ({ options, session }, conversation) => {
const presetLane = options.preset?.trim() || undefined
const target = conversation?.trim() || undefined
const seqs = target == null ? undefined : parseSeqs(target)
const includeArchived =
options.archived === true || options.all === true
await chain.receiveCommand(
Expand All @@ -248,7 +250,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) {
{
allPresetLanes: presetLane == null,
conversation_manage: {
targetConversation: conversation?.trim() || undefined,
targetConversation: seqs == null ? target : undefined,
targetConversationSeqs: seqs,
presetLane,
includeArchived
}
Expand Down Expand Up @@ -415,6 +418,27 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) {
})
}

function parseSeqs(input: string) {
if (!input.includes(',') && !input.includes('..')) return undefined
if (!/^\d+(?:\.\.\d+)?(?:,\d+(?:\.\.\d+)?)*$/.test(input)) {
return undefined
}

const seqs = new Set<number>()
for (const part of input.split(',')) {
const [start, end = start] = part.split('..').map(Number)
for (
let seq = Math.min(start, end);
seq <= Math.max(start, end);
seq += 1
) {
seqs.add(seq)
Comment on lines +430 to +435
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound sequence ranges before expansion

When a user runs a selector with a very large range, e.g. chatluna.delete 1..1000000000, this loop expands every integer into a Set before the middleware ever checks which conversations exist. Because chatluna.delete is available at authority 1, a short command can block the bot's event loop and/or exhaust memory; validate the range size or defer expansion until after listing the available entries.

Useful? React with 👍 / 👎.

}
}

return [...seqs]
}
Comment thread
dingyi222666 marked this conversation as resolved.

declare module '../chains/chain' {
interface ChainMiddlewareContextOptions {
conversation_create?: {
Expand All @@ -425,6 +449,7 @@ declare module '../chains/chain' {
}
conversation_manage?: {
targetConversation?: string
targetConversationSeqs?: number[]
presetLane?: string
includeArchived?: boolean
title?: string
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/middlewares/system/conversation_manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) {
try {
const { presetLane, includeArchived, allPresetLanes } =
getManageOptions(context)
const seqs =
context.options.conversation_manage?.targetConversationSeqs

if (seqs != null) {
context.message = await deleteBySeqs(ctx, session, seqs, {
presetLane,
includeArchived,
allPresetLanes
})
return ChainMiddlewareRunStatus.STOP
}

const conversation =
await ctx.chatluna.conversation.deleteConversation(session, {
conversationId: resolvedConversationId(context),
Expand Down Expand Up @@ -765,6 +777,36 @@ function getManageOptions(context: ChainMiddlewareContext) {
}
}

async function deleteBySeqs(
ctx: Context,
session: Session,
seqs: number[],
opts: ReturnType<typeof getManageOptions>
) {
const entries = await ctx.chatluna.conversation.listConversationEntries(
session,
opts
)
const targets = entries.filter((item) => seqs.includes(item.displaySeq))
if (targets.length !== seqs.length) {
return session.text('chatluna.conversation.messages.target_not_found')
}
Comment thread
dingyi222666 marked this conversation as resolved.

const deleted: ConversationRecord[] = []
for (const target of targets) {
deleted.push(
await ctx.chatluna.conversation.deleteConversation(session, {
conversationId: target.conversation.id,
...opts
})
)
}

return session.text('chatluna.conversation.messages.delete_success', [
deleted.map((item) => item.title).join('\n')
])
Comment on lines +805 to +807
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

i18n 消息参数不一致

批量删除和单个删除使用同一个 i18n 键 delete_success,但传递的参数不一致:

  • 单个删除(303-305 行):通过 conversationSummary 传递 [title, seq, id] 三个参数
  • 批量删除(805-807 行):仅传递 [joined_titles] 一个参数

如果 i18n 模板期望三个占位符(如 {0}, {1}, {2}),批量删除时访问 {1}{2} 可能导致显示错误或 undefined。

建议统一参数格式,或为批量删除使用独立的 i18n 键。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/middlewares/system/conversation_manage.ts` around lines 805
- 807, The bulk-delete branch uses
session.text('chatluna.conversation.messages.delete_success', [...]) with a
single parameter (deleted.map(...).join('\n')) while the single-delete flow
passes three parameters via conversationSummary ([title, seq, id]); unify these
so the i18n placeholders align by either (A) using a dedicated i18n key for bulk
deletes (e.g. 'chatluna.conversation.messages.delete_success_multi') and call
session.text with that key and the single joined title string, or (B) keep the
existing 'delete_success' key and pass the same three-argument shape from the
bulk branch (for example [joinedTitles, '', ''] or a concatenated seq/id string)
so the parameter count matches; update the call site (the session.text
invocation in the deleted.map(...).join(...) line) accordingly and add the new
i18n entry if you choose option A.

}

function conversationSummary(conversation: ConversationRecord) {
return [
conversation.title,
Expand Down
92 changes: 92 additions & 0 deletions packages/core/tests/conversation-resolve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { apply as applyResolve } from '../src/middlewares/conversation/resolve_c
import { apply as applyRequest } from '../src/middlewares/conversation/request_conversation'
import { apply as applyManage } from '../src/middlewares/system/conversation_manage'
import { apply as applyLifecycle } from '../src/middlewares/system/lifecycle'
import { apply as applyCommands } from '../src/commands/conversation'
import { ConversationResolutionError } from '../src/types'
import {
createConfig,
Expand Down Expand Up @@ -725,6 +726,97 @@ it('conversation_switch accepts resolved direct conversation ids', async () => {
}
})

it('chatluna.delete removes range and list selectors', async () => {
const conversations = [
createConversation({ id: 'conversation-first', title: 'First Topic' }),
createConversation({
id: 'conversation-second',
title: 'Second Topic'
}),
createConversation({ id: 'conversation-third', title: 'Third Topic' })
]
const actions = new Map<string, (...args: any[]) => Promise<void>>()
const deleted: string[] = []
const messages: string[] = []
let run:
| ((session: any, context: any) => Promise<ChainMiddlewareRunStatus>)
| undefined
const ctx = {
command: (decl: string) => {
const cmd = {
alias: () => cmd,
option: () => cmd,
action: (fn: (...args: any[]) => Promise<void>) => {
actions.set(decl, fn)
return cmd
}
}
return cmd
},
chatluna: {
conversation: {
listConversationEntries: async () =>
conversations.map((conversation, idx) => ({
conversation,
displaySeq: idx + 1
})),
deleteConversation: async (_session, opts) => {
deleted.push(opts.conversationId)
return conversations.find(
(item) => item.id === opts.conversationId
)
}
}
}
}
const chain = {
middleware: (name, fn) => {
if (name === 'conversation_delete') run = fn
return {
after() {
return this
},
before() {
return this
}
}
}
}

applyManage(ctx as never, {} as never, chain as never)
applyCommands(
ctx as never,
{} as never,
{
receiveCommand: async (session, name, opts) => {
await run!(session, { command: name, options: opts })
}
} as never
)

const session = createSession() as any
session.text = (key, params) => {
const msg = params == null ? key : `${key}:${params.join(',')}`
messages.push(msg)
return msg
}

const action = actions.get('chatluna.delete [conversation:string]')!
await action({ options: {}, session }, '1..2')
await action({ options: {}, session }, '1,3')

assert.deepEqual(deleted, [
'conversation-first',
'conversation-second',
'conversation-first',
'conversation-third'
])
assert.deepEqual(messages, [
'chatluna.conversation.messages.delete_success:First Topic\nSecond Topic',
'chatluna.conversation.messages.delete_success:First Topic\nThird Topic'
])
})

it('conversation_switch preserves explicit chain conversation through middleware order', async () => {
const { app, ctx } = await createMemoryService()

Expand Down
Loading