mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-27 21:41:02 +02:00
Backfill channel sender identity when it's available
This commit is contained in:
@@ -267,6 +267,13 @@ async def on_new_contact(event: "Event") -> None:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key.lower(), adv_name, int(time.time())
|
||||
)
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, etc.) matching the REST Contact shape exactly.
|
||||
|
||||
@@ -763,6 +763,16 @@ async def _process_advertisement(
|
||||
claimed,
|
||||
advert.public_key[:12],
|
||||
)
|
||||
if advert.name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(
|
||||
advert.public_key, advert.name
|
||||
)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
advert.name,
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly.
|
||||
|
||||
@@ -136,6 +136,17 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
claimed,
|
||||
public_key[:12],
|
||||
)
|
||||
adv_name = contact_data.get("adv_name")
|
||||
if adv_name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(
|
||||
public_key, adv_name
|
||||
)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
synced += 1
|
||||
|
||||
# Remove from radio
|
||||
|
||||
@@ -128,6 +128,22 @@ class MessageRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def backfill_channel_sender_key(public_key: str, name: str) -> int:
|
||||
"""Backfill sender_key on channel messages that match a contact's name.
|
||||
|
||||
When a contact becomes known (via advert, sync, or manual creation),
|
||||
any channel messages with a matching sender_name but no sender_key
|
||||
are updated to associate them with this contact's public key.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET sender_key = ?
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
|
||||
(public_key.lower(), name),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
def _normalize_conversation_key(conversation_key: str) -> tuple[str, str]:
|
||||
"""Normalize a conversation key and return (sql_clause, normalized_key).
|
||||
|
||||
@@ -154,6 +154,14 @@ async def create_contact(
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12])
|
||||
|
||||
# Backfill sender_key on channel messages that match this contact's name
|
||||
if request.name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name
|
||||
)
|
||||
|
||||
# Trigger historical decryption if requested
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
@@ -281,6 +289,15 @@ async def sync_contacts_from_radio() -> dict:
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
|
||||
adv_name = contact_data.get("adv_name")
|
||||
if adv_name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
count += 1
|
||||
|
||||
# Clear on_radio for contacts not found on the radio
|
||||
|
||||
@@ -84,6 +84,7 @@ export function App() {
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
|
||||
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
|
||||
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
|
||||
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
|
||||
|
||||
@@ -523,8 +524,9 @@ export function App() {
|
||||
setShowCracker((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleOpenContactInfo = useCallback((publicKey: string) => {
|
||||
const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => {
|
||||
setInfoPaneContactKey(publicKey);
|
||||
setInfoPaneFromChannel(fromChannel ?? false);
|
||||
}, []);
|
||||
|
||||
const handleCloseContactInfo = useCallback(() => {
|
||||
@@ -934,6 +936,7 @@ export function App() {
|
||||
|
||||
<ContactInfoPane
|
||||
contactKey={infoPaneContactKey}
|
||||
fromChannel={infoPaneFromChannel}
|
||||
onClose={handleCloseContactInfo}
|
||||
contacts={contacts}
|
||||
config={config}
|
||||
|
||||
@@ -20,6 +20,7 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
|
||||
interface ContactInfoPaneProps {
|
||||
contactKey: string | null;
|
||||
fromChannel?: boolean;
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
@@ -34,6 +35,7 @@ interface ContactInfoPaneProps {
|
||||
|
||||
export function ContactInfoPane({
|
||||
contactKey,
|
||||
fromChannel = false,
|
||||
onClose,
|
||||
contacts,
|
||||
config,
|
||||
@@ -117,6 +119,8 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
|
||||
{/* Block by name toggle */}
|
||||
{onToggleBlockedName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
@@ -186,6 +190,8 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -436,6 +442,18 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelAttributionWarning() {
|
||||
return (
|
||||
<div className="mx-5 my-3 px-3 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Channel sender identity is based on best-effort name matching. Different nodes using the
|
||||
same name will be attributed to the same contact. Message counts and key-based statistics
|
||||
may be inaccurate.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -27,7 +27,7 @@ interface MessageListProps {
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||
targetMessageId?: number | null;
|
||||
onTargetReached?: () => void;
|
||||
hasNewerMessages?: boolean;
|
||||
@@ -532,7 +532,11 @@ export function MessageList({
|
||||
role={onOpenContactInfo ? 'button' : undefined}
|
||||
tabIndex={onOpenContactInfo ? 0 : undefined}
|
||||
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
|
||||
onClick={onOpenContactInfo ? () => onOpenContactInfo(avatarKey) : undefined}
|
||||
onClick={
|
||||
onOpenContactInfo
|
||||
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for backfilling sender_key on channel messages when contacts become known."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repository import MessageRepository
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_sets_sender_key_on_matching_messages(test_db):
|
||||
"""Channel messages with a matching sender_name get sender_key backfilled."""
|
||||
pub_key = "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
|
||||
channel_key = "AA" * 16
|
||||
|
||||
# Store channel messages before the contact is known (sender_key=NULL)
|
||||
msg1 = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: hello",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
)
|
||||
msg2 = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: world",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=200,
|
||||
received_at=200,
|
||||
sender_name="Alice",
|
||||
)
|
||||
assert msg1 is not None
|
||||
assert msg2 is not None
|
||||
|
||||
# Verify sender_key is NULL before backfill
|
||||
messages = await MessageRepository.get_all(msg_type="CHAN", conversation_key=channel_key)
|
||||
assert all(m.sender_key is None for m in messages)
|
||||
|
||||
# Contact becomes known
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
|
||||
assert backfilled == 2
|
||||
|
||||
# Verify sender_key is now set
|
||||
messages = await MessageRepository.get_all(msg_type="CHAN", conversation_key=channel_key)
|
||||
assert all(m.sender_key == pub_key.lower() for m in messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_skips_messages_with_existing_sender_key(test_db):
|
||||
"""Messages that already have a sender_key are not overwritten."""
|
||||
pub_key_a = "aa" * 32
|
||||
pub_key_b = "bb" * 32
|
||||
channel_key = "CC" * 16
|
||||
|
||||
# Message already attributed to pub_key_a
|
||||
msg = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: hi",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
sender_key=pub_key_a,
|
||||
)
|
||||
assert msg is not None
|
||||
|
||||
# A different contact also named "Alice" appears
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key_b, "Alice")
|
||||
assert backfilled == 0
|
||||
|
||||
# Original attribution preserved
|
||||
messages = await MessageRepository.get_all(msg_type="CHAN", conversation_key=channel_key)
|
||||
assert messages[0].sender_key == pub_key_a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_only_affects_matching_name(test_db):
|
||||
"""Only messages from the matching sender_name are backfilled."""
|
||||
pub_key = "dd" * 32
|
||||
channel_key = "EE" * 16
|
||||
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: hello",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Bob: hello",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
sender_name="Bob",
|
||||
)
|
||||
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
|
||||
assert backfilled == 1
|
||||
|
||||
messages = await MessageRepository.get_all(msg_type="CHAN", conversation_key=channel_key)
|
||||
alice_msg = next(m for m in messages if m.sender_name == "Alice")
|
||||
bob_msg = next(m for m in messages if m.sender_name == "Bob")
|
||||
assert alice_msg.sender_key == pub_key.lower()
|
||||
assert bob_msg.sender_key is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_does_not_touch_dms(test_db):
|
||||
"""DM messages are never affected by channel sender backfill."""
|
||||
pub_key = "ff" * 32
|
||||
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="hello",
|
||||
conversation_key=pub_key,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
)
|
||||
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
|
||||
assert backfilled == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_idempotent(test_db):
|
||||
"""Running backfill twice has no effect the second time."""
|
||||
pub_key = "11" * 32
|
||||
channel_key = "22" * 16
|
||||
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: test",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
)
|
||||
|
||||
first = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
|
||||
assert first == 1
|
||||
|
||||
second = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
|
||||
assert second == 0
|
||||
Reference in New Issue
Block a user