Files
VoCat/internal/store/sms.go
T
MengMengCode 9fc3f1c5b8 feat: add Telegram API URL handling and version comparison utilities
- Implemented `telegramAPIURL` and `validateTelegramAPIURL` functions for constructing and validating Telegram API URLs.
- Added semantic versioning utilities in `version.go` to compare versions and validate semantic version formats.
- Created tests for version comparison logic in `version_test.go`.
- Introduced IMS call handling in `call_runtime.go`, including methods for dialing, answering, and hanging up calls.
- Added tests for incoming call handling and validation in `call_runtime_test.go`.
- Developed a new `PluginsCard` component for managing plugins via URL or file upload in the web interface.
- Implemented plugin management functions in `extensions.ts` and created an `ExtensionPage` for displaying plugin contributions.
2026-08-09 19:28:12 +08:00

628 lines
19 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
type contextQueryExecer interface {
contextExecer
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// SaveSMSMessage inserts a new message or updates an existing record. A
// non-empty (device_id, message_id) pair is idempotent for modem retries.
func (s *Store) SaveSMSMessage(ctx context.Context, value SMSMessage) (SMSMessage, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return SMSMessage{}, fmt.Errorf("begin SMS update: %w", err)
}
defer tx.Rollback()
saved, err := saveSMSMessage(ctx, tx, value)
if err != nil {
return SMSMessage{}, err
}
if err := tx.Commit(); err != nil {
return SMSMessage{}, fmt.Errorf("commit SMS update: %w", err)
}
return saved, nil
}
func saveSMSMessage(
ctx context.Context,
executor contextQueryExecer,
value SMSMessage,
) (SMSMessage, error) {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ModemIMEI = strings.TrimSpace(value.ModemIMEI)
value.Peer = strings.TrimSpace(value.Peer)
value.Direction = strings.ToLower(strings.TrimSpace(value.Direction))
if value.DeviceID == "" {
return SMSMessage{}, errors.New("SMS device id is required")
}
if value.Peer == "" {
return SMSMessage{}, errors.New("SMS peer is required")
}
switch value.Direction {
case "inbound", "outbound", "received", "sent":
default:
return SMSMessage{}, fmt.Errorf("unsupported SMS direction %q", value.Direction)
}
if value.PartsTotal == 0 {
value.PartsTotal = 1
}
if value.PartsTotal < 1 {
return SMSMessage{}, errors.New("SMS parts total must be positive")
}
extra, err := normalizeJSONObject(value.Extra)
if err != nil {
return SMSMessage{}, fmt.Errorf("normalize SMS extra data: %w", err)
}
now := time.Now().UTC()
if value.Timestamp.IsZero() {
value.Timestamp = now
}
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
if value.UpdatedAt.IsZero() {
value.UpdatedAt = now
}
if value.ID > 0 {
result, err := executor.ExecContext(ctx, `
UPDATE sms_messages SET
message_id = ?, device_id = ?, modem_imei = ?, imsi = ?, peer = ?,
direction = ?, body = ?, message_time = ?, status = ?,
source = ?, parts_total = ?, delivery_state = ?, is_read = ?,
extra_json = ?, updated_at = ?
WHERE id = ?
`,
value.MessageID, value.DeviceID, value.ModemIMEI, value.IMSI, value.Peer,
value.Direction, value.Body, value.Timestamp.Unix(), value.Status,
value.Source, value.PartsTotal, value.DeliveryState,
boolInt(value.Read), string(extra), value.UpdatedAt.Unix(), value.ID,
)
if err != nil {
return SMSMessage{}, fmt.Errorf("update SMS %d: %w", value.ID, err)
}
if err := requireAffected(result); err != nil {
return SMSMessage{}, err
}
return scanSMSMessage(executor.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, value.ID))
}
result, err := executor.ExecContext(ctx, `
INSERT INTO sms_messages (
message_id, device_id, modem_imei, imsi, peer, direction, body, message_time,
status, source, parts_total, delivery_state, is_read, extra_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE SET
device_id = excluded.device_id,
modem_imei = CASE
WHEN excluded.modem_imei <> '' THEN excluded.modem_imei
ELSE sms_messages.modem_imei
END,
imsi = excluded.imsi,
peer = excluded.peer,
direction = excluded.direction,
body = excluded.body,
message_time = MIN(sms_messages.message_time, excluded.message_time),
status = excluded.status,
source = excluded.source,
parts_total = excluded.parts_total,
delivery_state = excluded.delivery_state,
is_read = excluded.is_read,
extra_json = excluded.extra_json,
updated_at = excluded.updated_at
`,
value.MessageID, value.DeviceID, value.ModemIMEI, value.IMSI, value.Peer,
value.Direction, value.Body, value.Timestamp.Unix(), value.Status,
value.Source, value.PartsTotal, value.DeliveryState,
boolInt(value.Read), string(extra), value.CreatedAt.Unix(),
value.UpdatedAt.Unix(),
)
if err != nil {
return SMSMessage{}, fmt.Errorf("save SMS: %w", err)
}
if value.MessageID != "" {
hardwareKey := smsHardwareKey(value.ModemIMEI, value.DeviceID)
return scanSMSMessage(executor.QueryRowContext(
ctx,
smsMessageSelect+` WHERE
COALESCE(NULLIF(modem_imei, ''), 'device:' || device_id) = ?
AND message_id = ?`,
hardwareKey,
value.MessageID,
))
}
id, err := result.LastInsertId()
if err != nil {
return SMSMessage{}, fmt.Errorf("read inserted SMS id: %w", err)
}
return scanSMSMessage(executor.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, id))
}
func (s *Store) SaveSMSMessages(ctx context.Context, values []SMSMessage) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin SMS batch: %w", err)
}
defer tx.Rollback()
for index, value := range values {
if _, err := saveSMSMessage(ctx, tx, value); err != nil {
return fmt.Errorf("save SMS batch item %d: %w", index, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit SMS batch: %w", err)
}
return nil
}
func (s *Store) SMSMessage(ctx context.Context, id int64) (SMSMessage, error) {
return scanSMSMessage(s.db.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, id))
}
// LatestSMSMessageID returns the current durable cursor used by notification
// consumers. Starting at this value avoids replaying the entire SMS archive
// whenever the service or a notification provider is restarted.
func (s *Store) LatestSMSMessageID(ctx context.Context) (int64, error) {
var id int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM sms_messages`).Scan(&id); err != nil {
return 0, fmt.Errorf("read latest SMS id: %w", err)
}
return id, nil
}
// ListInboundSMSAfterID returns newly inserted inbound messages in durable ID
// order. Telegram advances this cursor only after considering each item, so
// timestamp corrections and duplicate modem synchronisations cannot reorder or
// duplicate notifications.
func (s *Store) ListInboundSMSAfterID(ctx context.Context, afterID int64, limit int) ([]SMSMessage, error) {
if afterID < 0 {
afterID = 0
}
rows, err := s.db.QueryContext(ctx, smsMessageSelect+`
WHERE id > ? AND direction IN ('inbound', 'received')
ORDER BY id ASC
LIMIT ?`, afterID, normalizedLimit(limit))
if err != nil {
return nil, fmt.Errorf("list new inbound SMS messages: %w", err)
}
defer rows.Close()
values := make([]SMSMessage, 0)
for rows.Next() {
value, scanErr := scanSMSMessage(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan new inbound SMS message: %w", scanErr)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate new inbound SMS messages: %w", err)
}
return values, nil
}
// ApplySMSDeliveryReport attaches a TP-STATUS report to the newest matching
// outbound submission and advances its aggregate delivery state. Multipart
// messages become delivered only after every submitted part is reported.
func (s *Store) ApplySMSDeliveryReport(ctx context.Context, report SMSDeliveryReport) (SMSMessage, error) {
report.DeviceID = strings.TrimSpace(report.DeviceID)
report.ModemIMEI = strings.TrimSpace(report.ModemIMEI)
if (report.DeviceID == "" && report.ModemIMEI == "") || report.MessageReference < 0 || report.MessageReference > 255 {
return SMSMessage{}, errors.New("invalid SMS delivery report identity")
}
if report.ReceivedAt.IsZero() {
report.ReceivedAt = time.Now().UTC()
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return SMSMessage{}, fmt.Errorf("begin SMS delivery report: %w", err)
}
defer tx.Rollback()
query := smsMessageSelect + `
WHERE ((? <> '' AND modem_imei = ?) OR (? = '' AND device_id = ?))
AND direction IN ('outbound', 'sent')
AND (? = '' OR imsi = ?)
AND (? = '' OR peer = ?)
AND (? = '' OR source = ?)
ORDER BY created_at DESC, id DESC
LIMIT 256`
rows, err := tx.QueryContext(
ctx,
query,
report.ModemIMEI, report.ModemIMEI, report.ModemIMEI, report.DeviceID,
report.IMSI, report.IMSI,
report.Peer, report.Peer,
report.Source, report.Source,
)
if err != nil {
return SMSMessage{}, fmt.Errorf("find SMS delivery target: %w", err)
}
var target SMSMessage
var targetExtra map[string]any
for rows.Next() {
candidate, scanErr := scanSMSMessage(rows)
if scanErr != nil {
_ = rows.Close()
return SMSMessage{}, scanErr
}
extra := make(map[string]any)
if json.Unmarshal(candidate.Extra, &extra) != nil || !smsExtraHasReference(extra, report.MessageReference) {
continue
}
target, targetExtra = candidate, extra
break
}
if err := rows.Close(); err != nil {
return SMSMessage{}, err
}
if target.ID == 0 {
return SMSMessage{}, ErrNotFound
}
reports, _ := targetExtra["delivery_reports"].(map[string]any)
if reports == nil {
reports = make(map[string]any)
}
reportValue := map[string]any{
"status_code": report.StatusCode,
"delivery_state": report.DeliveryState,
"received_at": report.ReceivedAt.UTC(),
}
if report.ServiceCenterTime != nil {
reportValue["service_center_timestamp"] = report.ServiceCenterTime.UTC()
}
if report.DischargeTime != nil {
reportValue["discharge_timestamp"] = report.DischargeTime.UTC()
}
reports[strconv.Itoa(report.MessageReference)] = reportValue
targetExtra["delivery_reports"] = reports
target.DeliveryState = aggregateSMSDeliveryState(targetExtra, reports)
target.Extra, err = json.Marshal(targetExtra)
if err != nil {
return SMSMessage{}, fmt.Errorf("encode SMS delivery reports: %w", err)
}
target.UpdatedAt = time.Now().UTC()
saved, err := saveSMSMessage(ctx, tx, target)
if err != nil {
return SMSMessage{}, err
}
if err := tx.Commit(); err != nil {
return SMSMessage{}, fmt.Errorf("commit SMS delivery report: %w", err)
}
return saved, nil
}
func smsExtraHasReference(extra map[string]any, reference int) bool {
if numberAsInt(extra["message_reference"]) == reference {
return true
}
parts, _ := extra["part_results"].([]any)
for _, value := range parts {
part, _ := value.(map[string]any)
if numberAsInt(part["reference"]) == reference ||
numberAsInt(part["messageReference"]) == reference ||
numberAsInt(part["message_reference"]) == reference {
return true
}
}
return false
}
func aggregateSMSDeliveryState(extra map[string]any, reports map[string]any) string {
parts, _ := extra["part_results"].([]any)
references := make([]int, 0, len(parts))
for _, value := range parts {
part, _ := value.(map[string]any)
reference := numberAsInt(part["reference"])
if reference < 0 {
reference = numberAsInt(part["messageReference"])
}
if reference < 0 {
reference = numberAsInt(part["message_reference"])
}
if reference >= 0 {
references = append(references, reference)
}
}
if len(references) == 0 {
if reference := numberAsInt(extra["message_reference"]); reference >= 0 {
references = append(references, reference)
}
}
if len(references) == 0 {
return "unknown"
}
delivered := 0
for _, reference := range references {
value, found := reports[strconv.Itoa(reference)]
if !found {
continue
}
report, _ := value.(map[string]any)
state, _ := report["delivery_state"].(string)
switch state {
case "delivered":
delivered++
case "permanent_error", "failed", "rejected":
return "failed"
}
}
if delivered == len(references) {
return "delivered"
}
return "pending_delivery_report"
}
func numberAsInt(value any) int {
switch number := value.(type) {
case float64:
return int(number)
case int:
return number
case json.Number:
parsed, err := strconv.Atoi(string(number))
if err == nil {
return parsed
}
}
return -1
}
func (s *Store) ListSMSMessages(ctx context.Context, filter SMSFilter) ([]SMSMessage, error) {
where, args := smsWhere(filter, "")
query := smsMessageSelect + where + ` ORDER BY message_time DESC, id DESC LIMIT ?`
args = append(args, normalizedLimit(filter.Limit))
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list SMS messages: %w", err)
}
defer rows.Close()
values := make([]SMSMessage, 0)
for rows.Next() {
value, err := scanSMSMessage(rows)
if err != nil {
return nil, fmt.Errorf("scan SMS message: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate SMS messages: %w", err)
}
return values, nil
}
func (s *Store) DeleteSMSMessage(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM sms_messages WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete SMS %d: %w", id, err)
}
return requireAffected(result)
}
func (s *Store) DeleteSMSThread(
ctx context.Context,
deviceID string,
imsi string,
peer string,
) (int64, error) {
result, err := s.db.ExecContext(ctx, `
DELETE FROM sms_messages
WHERE device_id = ? AND imsi = ? AND peer = ?
`, deviceID, imsi, peer)
if err != nil {
return 0, fmt.Errorf("delete SMS thread: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("read deleted SMS count: %w", err)
}
if affected == 0 {
return 0, ErrNotFound
}
return affected, nil
}
func (s *Store) MarkSMSThreadRead(
ctx context.Context,
deviceID string,
imsi string,
peer string,
) (int64, error) {
result, err := s.db.ExecContext(ctx, `
UPDATE sms_messages
SET is_read = 1, updated_at = ?
WHERE device_id = ? AND imsi = ? AND peer = ?
AND direction IN ('inbound', 'received') AND is_read = 0
`, time.Now().UTC().Unix(), deviceID, imsi, peer)
if err != nil {
return 0, fmt.Errorf("mark SMS thread read: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("read marked SMS count: %w", err)
}
return affected, 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) {
where, args := smsWhere(filter, "m.")
query := `
WITH resolved AS (
SELECT
m.*,
COALESCE(NULLIF(m.modem_imei, ''), 'device:' || m.device_id) AS hardware_key,
COALESCE((
SELECT current_device.id
FROM devices current_device
WHERE m.modem_imei <> ''
AND current_device.modem_imei = m.modem_imei
ORDER BY current_device.updated_at DESC, current_device.id
LIMIT 1
), m.device_id) AS resolved_device_id
FROM sms_messages m` + where + `
), ranked AS (
SELECT
m.id, m.resolved_device_id, m.modem_imei, m.imsi, m.peer,
m.body, m.message_time, m.direction,
ROW_NUMBER() OVER (
PARTITION BY m.hardware_key, m.imsi, m.peer
ORDER BY m.message_time DESC, m.id DESC
) AS row_number,
SUM(CASE
WHEN m.direction IN ('inbound', 'received') AND m.is_read = 0
THEN 1 ELSE 0
END) OVER (
PARTITION BY m.hardware_key, m.imsi, m.peer
) AS unread_count,
COUNT(*) OVER (
PARTITION BY m.hardware_key, m.imsi, m.peer
) AS message_count
FROM resolved m
)
SELECT
r.resolved_device_id,
COALESCE(d.name, ''),
r.modem_imei,
r.imsi,
COALESCE(NULLIF(dr.phone_number, ''), NULLIF(vr.local_phone, ''), ''),
r.peer,
r.peer,
r.body,
r.message_time,
r.direction,
r.id,
r.unread_count,
r.message_count
FROM ranked r
LEFT JOIN devices d ON d.id = r.resolved_device_id
LEFT JOIN device_runtime dr ON dr.device_id = r.resolved_device_id
LEFT JOIN vowifi_runtime vr ON vr.device_id = r.resolved_device_id
WHERE r.row_number = 1
ORDER BY r.message_time DESC, r.id DESC
LIMIT ?`
args = append(args, normalizedLimit(filter.Limit))
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list SMS contacts: %w", err)
}
defer rows.Close()
values := make([]SMSContact, 0)
for rows.Next() {
var value SMSContact
var timestamp int64
if err := rows.Scan(
&value.DeviceID, &value.DeviceName, &value.ModemIMEI, &value.IMSI,
&value.LocalPhone, &value.Peer, &value.DisplayName,
&value.LastMessage, &timestamp, &value.Direction,
&value.LastSMSID, &value.UnreadCount, &value.MessageCount,
); err != nil {
return nil, fmt.Errorf("scan SMS contact: %w", err)
}
value.LastTimestamp = time.Unix(timestamp, 0).UTC()
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate SMS contacts: %w", err)
}
return values, nil
}
const smsMessageSelect = `
SELECT id, message_id, device_id, modem_imei, imsi, peer, direction, body,
message_time, status, source, parts_total, delivery_state, is_read,
extra_json, created_at, updated_at
FROM sms_messages`
func scanSMSMessage(row rowScanner) (SMSMessage, error) {
var value SMSMessage
var messageTime, createdAt, updatedAt int64
var read int
var extra string
err := row.Scan(
&value.ID, &value.MessageID, &value.DeviceID, &value.ModemIMEI, &value.IMSI,
&value.Peer, &value.Direction, &value.Body, &messageTime,
&value.Status, &value.Source, &value.PartsTotal,
&value.DeliveryState, &read, &extra, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return SMSMessage{}, ErrNotFound
}
if err != nil {
return SMSMessage{}, err
}
value.Read = read != 0
value.Extra = []byte(extra)
value.Timestamp = time.Unix(messageTime, 0).UTC()
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil
}
func smsWhere(filter SMSFilter, prefix string) (string, []any) {
clauses := make([]string, 0, 6)
args := make([]any, 0, 6)
if filter.DeviceID != "" {
clauses = append(clauses, prefix+`device_id = ?`)
args = append(args, filter.DeviceID)
}
if filter.ModemIMEI != "" {
clauses = append(clauses, prefix+`modem_imei = ?`)
args = append(args, filter.ModemIMEI)
}
if filter.IMSI != "" {
clauses = append(clauses, prefix+`imsi = ?`)
args = append(args, filter.IMSI)
}
if filter.Peer != "" {
clauses = append(clauses, prefix+`peer = ?`)
args = append(args, filter.Peer)
}
if !filter.Since.IsZero() {
clauses = append(clauses, prefix+`message_time >= ?`)
args = append(args, filter.Since.UTC().Unix())
}
if !filter.Until.IsZero() {
clauses = append(clauses, prefix+`message_time < ?`)
args = append(args, filter.Until.UTC().Unix())
}
if filter.BeforeID > 0 {
clauses = append(clauses, prefix+`id < ?`)
args = append(args, filter.BeforeID)
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
func smsHardwareKey(modemIMEI, deviceID string) string {
if modemIMEI = strings.TrimSpace(modemIMEI); modemIMEI != "" {
return modemIMEI
}
return "device:" + strings.TrimSpace(deviceID)
}
func normalizedLimit(value int) int {
if value <= 0 {
return 100
}
if value > 1000 {
return 1000
}
return value
}