feat: implement SMS management features including frontend UI and backend API handlers

This commit is contained in:
MengMengCode
2026-08-20 15:35:10 +08:00
parent 53345d2915
commit 4f3f37ba7c
4 changed files with 179 additions and 152 deletions
+10 -4
View File
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
s.writeStoreError(w, err)
return
}
for _, message := range messages {
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
message.Read = true
_, _ = s.store.SaveSMSMessage(r.Context(), message)
unreadIDs := make([]int64, 0, len(messages))
for i := range messages {
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
messages[i].Read = true
unreadIDs = append(unreadIDs, messages[i].ID)
}
}
if len(unreadIDs) > 0 {
if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil {
s.logger.Warn("mark SMS messages read failed", "error", markErr)
}
}
reverseSMS(messages)
+14
View File
@@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread should be read: %+v", contacts)
}
// A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0.
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
Timestamp: base, Status: "received", Read: false,
}); err != nil {
t.Fatal(err)
}
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread read state must survive modem rescan: %+v", contacts)
}
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
if err != nil || deleted != 2 {
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
+34 -9
View File
@@ -92,15 +92,16 @@ func saveSMSMessage(
if mergeErr != nil {
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
}
if existingErr == nil && !changed {
// This segment is already folded into the stored row (a periodic modem
// rescan redelivers every segment). Leave the row untouched so the
// durable id stays put and Telegram does not re-notify.
return existing, nil
}
value.Body = mergedBody
extra = mergedExtra
if existingErr == nil {
if !changed {
if value.Read != existing.Read {
if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil {
return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err)
}
existing.Read = value.Read
}
return existing, nil
}
// A new segment advanced the message. Replace the stale partial row so
// the merged row receives a fresh durable id; the Telegram id-cursor
// then surfaces the now-more-complete message exactly once. Carry
@@ -116,6 +117,8 @@ func saveSMSMessage(
value.Timestamp = existing.Timestamp
}
}
value.Body = mergedBody
extra = mergedExtra
}
if value.Timestamp.IsZero() {
value.Timestamp = now
@@ -171,7 +174,10 @@ func saveSMSMessage(
source = excluded.source,
parts_total = excluded.parts_total,
delivery_state = excluded.delivery_state,
is_read = excluded.is_read,
is_read = CASE
WHEN sms_messages.is_read = 1 THEN 1
ELSE excluded.is_read
END,
extra_json = excluded.extra_json,
updated_at = excluded.updated_at
`,
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
return affected, nil
}
func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error {
if len(ids) == 0 {
return nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, time.Now().UTC().Unix())
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ","))
_, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("mark SMS messages read: %w", err)
}
return nil
}
// ListSMSContacts derives contacts and thread counters from messages. No
// duplicated contact/thread table can drift out of sync with message history.
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {