fix(contacts): consistent sender_id format and show contact_type in UI

- Use senderID (id|username) instead of userID in group no-mention path,
  preventing duplicate contacts for the same Telegram user
- Use full name (FirstName + LastName) in both contact insert paths
- Show contact_type (User/Group) instead of peer_kind (Direct/Group) in
  contacts table TYPE column and filter dropdown
- Add contact_type filter support in HTTP handler, PG and SQLite stores
This commit is contained in:
viettranx committed 2026-03-31 14:18:09 +07:00
1 parent 36b43ed7f1
commit 4e9ce0e0e1
11 files changed
+44 -17

No files matched your search

+3 -2
View File
@@ -336,7 +336,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
contactName := strings.TrimSpace(user.FirstName + " " + user.LastName)
cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, contactName, user.Username, "group", "user")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, userID, contactName, user.Username, "group", "user")
// Also collect group chat itself as a contact (for group permission / merge).
cc.EnsureContact(ctx, c.Type(), c.Name(), chatIDStr, "", message.Chat.Title, "", "group", "group")
}
@@ -590,7 +590,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
// Collect contact for processed messages (DM + group-mentioned).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, userID, user.FirstName, user.Username, peerKind, "user")
contactName := strings.TrimSpace(user.FirstName + " " + user.LastName)
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, userID, contactName, user.Username, peerKind, "user")
// Also collect group chat itself as a contact (for group permission / merge).
if isGroup {
cc.EnsureContact(ctx, c.Type(), c.Name(), chatIDStr, "", message.Chat.Title, "", "group", "group")
+3
View File
@@ -476,6 +476,9 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h
if v := r.URL.Query().Get("peer_kind"); v != "" {
opts.PeerKind = v
}
if v := r.URL.Query().Get("contact_type"); v != "" {
opts.ContactType = v
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
opts.Limit = n
+1
View File
@@ -30,6 +30,7 @@ type ContactListOpts struct {
Search string // ILIKE on display_name, username, sender_id
ChannelType string // filter by platform (telegram, discord, etc.)
PeerKind string // "direct" or "group"
ContactType string // "user" or "group"
Limit int
Offset int
}
+5
View File
@@ -68,6 +68,11 @@ func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string
args = append(args, opts.PeerKind)
argIdx++
}
if opts.ContactType != "" {
conditions = append(conditions, fmt.Sprintf("contact_type = $%d", argIdx))
args = append(args, opts.ContactType)
argIdx++
}
if opts.Search != "" {
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
pattern := escaped + "%"
+4
View File
@@ -72,6 +72,10 @@ func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string
conditions = append(conditions, "peer_kind = ?")
args = append(args, opts.PeerKind)
}
if opts.ContactType != "" {
conditions = append(conditions, "contact_type = ?")
args = append(args, opts.ContactType)
}
if opts.Search != "" {
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
pattern := escaped + "%"
+4
View File
@@ -45,6 +45,10 @@
"unmergeError": "Failed to unmerge contacts",
"comingSoon": "This feature is under development and not yet available."
},
"types": {
"user": "User",
"group": "Group"
},
"filters": {
"allChannels": "All Channels",
"allTypes": "All Types",
+4
View File
@@ -45,6 +45,10 @@
"unmergeError": "Không thể tách liên hệ",
"comingSoon": "Tính năng này đang được phát triển và chưa khả dụng."
},
"types": {
"user": "Người dùng",
"group": "Nhóm"
},
"filters": {
"allChannels": "Tất cả Channel",
"allTypes": "Tất cả loại",
+4
View File
@@ -45,6 +45,10 @@
"unmergeError": "取消合并失败",
"comingSoon": "此功能正在开发中,尚不可用。"
},
"types": {
"user": "用户",
"group": "群组"
},
"filters": {
"allChannels": "所有Channel",
"allTypes": "所有类型",
+13 -15
View File
@@ -33,7 +33,7 @@ export function ContactsPage() {
const [search, setSearch] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const [channelType, setChannelType] = useState("");
const [peerKind, setPeerKind] = useState("");
const [contactType, setContactType] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -44,7 +44,7 @@ export function ContactsPage() {
const { contacts, total, loading, fetching, refresh } = useContacts({
search: appliedSearch || undefined,
channelType: channelType || undefined,
peerKind: peerKind || undefined,
contactType: contactType || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
});
@@ -57,7 +57,7 @@ export function ContactsPage() {
// Clear selection on page/filter change
useEffect(() => {
setSelectedIds(new Set());
}, [page, pageSize, appliedSearch, channelType, peerKind]);
}, [page, pageSize, appliedSearch, channelType, contactType]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -70,8 +70,8 @@ export function ContactsPage() {
setPage(1);
};
const handlePeerKindChange = (val: string) => {
setPeerKind(val === "all" ? "" : val);
const handleContactTypeChange = (val: string) => {
setContactType(val === "all" ? "" : val);
setPage(1);
};
@@ -149,14 +149,14 @@ export function ContactsPage() {
</SelectContent>
</Select>
<Select value={peerKind || "all"} onValueChange={handlePeerKindChange}>
<Select value={contactType || "all"} onValueChange={handleContactTypeChange}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
<SelectItem value="direct">{t("filters.direct")}</SelectItem>
<SelectItem value="group">{t("filters.group")}</SelectItem>
<SelectItem value="user">{t("types.user")}</SelectItem>
<SelectItem value="group">{t("types.group")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -187,8 +187,8 @@ export function ContactsPage() {
) : contacts.length === 0 ? (
<EmptyState
icon={Contact}
title={appliedSearch || channelType || peerKind ? t("noMatchTitle") : t("emptyTitle")}
description={appliedSearch || channelType || peerKind ? t("noMatchDescription") : t("emptyDescription")}
title={appliedSearch || channelType || contactType ? t("noMatchTitle") : t("emptyTitle")}
description={appliedSearch || channelType || contactType ? t("noMatchDescription") : t("emptyDescription")}
/>
) : (
<div className="rounded-md border overflow-x-auto">
@@ -249,11 +249,9 @@ export function ContactsPage() {
<Badge variant="outline" className="text-[11px]">{c.channel_type}</Badge>
</td>
<td className="px-3 py-2.5">
{c.peer_kind && (
<Badge variant={c.peer_kind === "direct" ? "default" : "secondary"} className="text-[11px]">
{c.peer_kind === "direct" ? t("filters.direct") : t("filters.group")}
</Badge>
)}
<Badge variant={c.contact_type === "user" ? "default" : "secondary"} className="text-[11px]">
{c.contact_type === "user" ? t("types.user") : t("types.group")}
</Badge>
</td>
<td className="px-3 py-2.5 text-muted-foreground text-xs">
{formatDate(c.last_seen_at)}
@@ -10,6 +10,7 @@ export interface ContactFilters {
search?: string;
channelType?: string;
peerKind?: string;
contactType?: string;
limit?: number;
offset?: number;
}
@@ -27,6 +28,7 @@ export function useContacts(filters: ContactFilters = {}) {
if (filters.search) params.search = filters.search;
if (filters.channelType) params.channel_type = filters.channelType;
if (filters.peerKind) params.peer_kind = filters.peerKind;
if (filters.contactType) params.contact_type = filters.contactType;
if (filters.limit) params.limit = String(filters.limit);
if (filters.offset !== undefined) params.offset = String(filters.offset);
+1
View File
@@ -19,6 +19,7 @@ export interface ChannelContact {
username?: string;
avatar_url?: string;
peer_kind?: string;
contact_type: string; // "user" or "group"
merged_id?: string;
first_seen_at: string;
last_seen_at: string;