Skip to content
Merged
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 src/channels/telegram/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function topicHelpText(): string {
"会话处理:",
"/priority low|normal|high|urgent - 设置优先级",
"/assign <telegram_user_id> - 分配负责人",
"/mine - 查看分配给自己的会话列表",
"/close - 关闭会话;用户再发消息会自动重开",
"/reopen 或 /open - 重新打开会话",
"/mute <时长> - 静音提醒,例如 /mute 2h、/mute 1d",
Expand Down Expand Up @@ -413,6 +414,21 @@ export async function handleTopicCommand(
await ctx.reply("已对该会话关闭 AI 草稿。该会话不再自动生成回复草稿。");
return true;
}
case "mine": {
const items = deps.conversations.listByAssignee(adminId, 20);
if (items.length === 0) {
await ctx.reply("当前没有分配给你的会话。使用 /assign <你的 user_id> 分配给自己。");
return true;
}
const lines = items.map((c) => {
const name = c.contactDisplayName ?? c.topicName ?? `#${c.id}`;
const status = c.status === "open" ? "" : " [closed]";
const last = c.lastMessageAt ? ` 最后:${c.lastMessageAt}` : "";
return `#${c.id} ${name}${status}${last}`;
});
await ctx.reply([`分配给你的会话 (${items.length}):`, ...lines].join("\n"));
return true;
}
case "search": {
if (!args) {
await ctx.reply("用法:/search <关键词>,在当前会话历史消息中搜索。");
Expand Down
18 changes: 16 additions & 2 deletions src/domain/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,11 +567,21 @@ export class ConversationService {

listConversations(opts: {
status?: "open" | "closed";
assignedTo?: string;
limit: number;
offset: number;
}): { items: ConversationListItem[]; total: number } {
const where = opts.status ? "WHERE c.status = ?" : "";
const params = opts.status ? [opts.status] : [];
const conditions: string[] = [];
const params: Array<string | number> = [];
if (opts.status) {
conditions.push("c.status = ?");
params.push(opts.status);
}
if (opts.assignedTo !== undefined) {
conditions.push("c.assigned_admin_id = ?");
params.push(opts.assignedTo);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = this.db
.prepare(
`SELECT c.id, c.status, c.priority, c.assigned_admin_id, c.created_at, c.last_message_at,
Expand All @@ -590,6 +600,10 @@ export class ConversationService {
return { items: rows.map(conversationListItemFromRow), total: countRow.cnt };
}

listByAssignee(adminId: string, limit: number): ConversationListItem[] {
return this.listConversations({ assignedTo: adminId, limit, offset: 0 }).items;
}

searchMessagesInConversation(conversationId: number, query: string, limit: number): Message[] {
const pattern = `%${query.replace(/[%_]/g, (m) => "\\" + m)}%`;
const rows = this.db
Expand Down
1 change: 1 addition & 0 deletions src/runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ await startWebConsole({
);
const result = conversations.listConversations({
status: opts.status === "open" || opts.status === "closed" ? opts.status : undefined,
assignedTo: opts.assignedTo || undefined,
limit: opts.pageSize,
offset: (opts.page - 1) * opts.pageSize,
});
Expand Down
33 changes: 24 additions & 9 deletions src/runtime/web-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export interface WebConsoleOptions {
dbHealthCheck: () => boolean;
collectMetrics: () => MetricsSnapshot;
collectOperationsOverview: () => OperationsOverview;
listConversations: (opts: { page: number; status?: string; pageSize: number }) => { items: ConversationListItemView[]; total: number };
listConversations: (opts: { page: number; status?: string; assignedTo?: string; pageSize: number }) => { items: ConversationListItemView[]; total: number };
listFailedDeliveries: (opts: { page: number; pageSize: number }) => { items: DeliveryView[]; total: number };
scheduleRetry: (deliveryId: number) => Promise<void>;
listAuditLogs: (opts: { page: number; adminId?: string; action?: string; pageSize: number }) => { items: AuditLogView[]; total: number };
Expand Down Expand Up @@ -376,8 +376,9 @@ export async function startWebConsole(options: WebConsoleOptions): Promise<Serve
if (url.pathname === "/operations/conversations") {
const pageNum = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
const status = url.searchParams.get("status") ?? undefined;
const data = options.listConversations({ page: pageNum, status, pageSize: 50 });
renderConversations(res, data, pageNum, status);
const assignedTo = url.searchParams.get("assigned_to") ?? undefined;
const data = options.listConversations({ page: pageNum, status, assignedTo, pageSize: 50 });
renderConversations(res, data, pageNum, status, assignedTo);
return;
}

Expand Down Expand Up @@ -918,17 +919,30 @@ function renderConversations(
data: { items: ConversationListItemView[]; total: number },
page: number,
status: string | undefined,
assignedTo: string | undefined,
): void {
const pageSize = 50;
const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
const filterLinks = ["", "open", "closed"]
.map((s) => {
const label = s === "" ? "全部" : s === "open" ? "open" : "closed";
const active = (status ?? "") === s ? "current" : "";
const qs = s ? `?status=${s}` : "";
return `<a href="/operations/conversations${qs}" class="${active}">${label}</a>`;
const params = new URLSearchParams();
if (s) params.set("status", s);
if (assignedTo) params.set("assigned_to", assignedTo);
const qs = params.toString();
return `<a href="/operations/conversations${qs ? `?${qs}` : ""}" class="${active}">${label}</a>`;
})
.join("");
const assignedValue = assignedTo ? escapeHtml(assignedTo) : "";
const buildQs = (p: number): string => {
const params = new URLSearchParams();
if (status) params.set("status", status);
if (assignedTo) params.set("assigned_to", assignedTo);
if (p > 1) params.set("page", String(p));
const qs = params.toString();
return qs ? `?${qs}` : "";
};
const rows = data.items.length
? data.items
.map(
Expand All @@ -945,14 +959,15 @@ function renderConversations(
.join("")
: `<tr><td colspan="7" class="ops-empty">没有符合条件的会话。</td></tr>`;
const pager = Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1)
.map(
(p) =>
`<a href="/operations/conversations${status ? `?status=${status}&` : "?"}page=${p}" class="${p === page ? "current" : ""}">${p}</a>`,
)
.map((p) => `<a href="/operations/conversations${buildQs(p)}" class="${p === page ? "current" : ""}">${p}</a>`)
.join("");
const body = `
<h2 class="ops-section-title">会话列表 (${data.total})</h2>
<div class="ops-pager" style="justify-content:flex-start;margin-bottom:16px">${filterLinks}</div>
<form method="get" action="/operations/conversations" style="display:flex;gap:12px;margin-bottom:16px;align-items:flex-end">
<div><label style="font-size:12px;color:var(--color-text-secondary)">负责人 ID</label><br><input type="text" name="assigned_to" value="${assignedValue}" placeholder="可选" style="padding:6px 10px;border:1px solid var(--color-border);border-radius:8px"></div>
<button type="submit" class="ops-btn">筛选</button>
</form>
<table class="ops-table">
<thead><tr><th>ID</th><th>Topic</th><th>联系人</th><th>状态</th><th>优先级</th><th>负责人</th><th>最后消息</th></tr></thead>
<tbody>${rows}</tbody>
Expand Down
20 changes: 20 additions & 0 deletions test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,26 @@ describe("conversation service", () => {
const paged = service.listConversations({ limit: 2, offset: 0 });
assert.equal(paged.items.length, 2);
});

it("lists conversations by assignee", async () => {
const service = new ConversationService(handle.db, 30);
const b1 = await service.getOrCreateConversation({ platform: "telegram", externalUserId: "600", displayName: "A" });
const b2 = await service.getOrCreateConversation({ platform: "telegram", externalUserId: "601", displayName: "B" });
const b3 = await service.getOrCreateConversation({ platform: "telegram", externalUserId: "602", displayName: "C" });
service.assign(b1.conversation.id, "100");
service.assign(b2.conversation.id, "100");
service.assign(b3.conversation.id, "200");

const mine = service.listByAssignee("100", 20);
assert.equal(mine.length, 2);
assert.ok(mine.every((c) => c.assignedAdminId === "100"));

const combined = service.listConversations({ assignedTo: "100", status: "open", limit: 50, offset: 0 });
assert.equal(combined.total, 2);

const empty = service.listByAssignee("999", 20);
assert.equal(empty.length, 0);
});
});

describe("permissions and rate limits", () => {
Expand Down