Files
VoCat/web/src/components/sms/smsText.ts
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

164 lines
5.3 KiB
TypeScript

import type { SMSContact, SMSMessage } from "../../types";
import { tl, useI18n } from "../../lib/i18n";
// Aggregated conversation row built from an SMSContact (mirrors the VoHive `Os` mapper).
export interface SmsThread {
key: string;
modemImei: string;
imsi: string;
peer: string;
deviceId: string;
lastTs: number;
lastSmsId: number;
lastMessage: string;
lastDeviceName?: string;
localPhone: string;
peerLower: string;
lastMessageLower: string;
// Authoritative read state from the database (unreadCount from /sms/contacts).
unread: boolean;
}
// Long-press action-sheet target (mobile).
export type SmsActionTarget =
| { type: "thread"; thread: SmsThread }
| { type: "message"; message: SMSMessage };
export interface SmsEncodingInfo {
encoding: "GSM7" | "UCS2";
parts: number;
units: number;
unitName: "septets" | "chars";
}
// GSM 03.38 basic character set (LF + CR included) and extension table (counts as 2 septets).
const GSM7_BASIC = new Set(
Array.from(`@£$¥èéùìòÇ
Øø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ !"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà`),
);
const GSM7_EXT = new Set(Array.from("^{}\\[~]|€"));
// Segment/encoding estimator, faithful to the reference `Ge`.
export function analyzeSmsEncoding(text: string): SmsEncodingInfo {
const s = String(text || "");
let units = 0;
let gsm = true;
for (const ch of Array.from(s)) {
if (GSM7_BASIC.has(ch)) {
units += 1;
continue;
}
if (GSM7_EXT.has(ch)) {
units += 2;
continue;
}
gsm = false;
break;
}
if (gsm) {
return { encoding: "GSM7", parts: units <= 160 ? 1 : Math.ceil(units / 153), units, unitName: "septets" };
}
const chars = Array.from(s).length;
return { encoding: "UCS2", parts: chars <= 70 ? 1 : Math.ceil(chars / 67), units: chars, unitName: "chars" };
}
export function toTs(value?: string | number | null): number {
const t = new Date(value as string).getTime();
return Number.isFinite(t) ? t : 0;
}
export function timeLabel(ts: number): string {
return ts ? new Date(ts).toLocaleTimeString() : "";
}
export function dayLabel(ts: number): string {
const d = new Date(ts);
if (!Number.isFinite(d.getTime())) return tl("未知日期");
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function deriveThread(c: SMSContact): SmsThread {
const lastMessage = String(c.lastContent ?? c.lastMessage ?? "").slice(0, 80);
const modemImei = String(c.modemImei || "").trim();
return {
key: `${modemImei || `device:${c.deviceId}`}|${c.imsi}|${c.peer}`,
modemImei,
imsi: c.imsi,
peer: c.peer,
deviceId: c.deviceId,
lastTs: toTs(c.lastTimestamp),
lastSmsId: c.lastSmsId || 0,
lastMessage,
lastDeviceName: c.deviceName,
localPhone: c.localPhone || "",
peerLower: String(c.peer || "").toLowerCase(),
lastMessageLower: lastMessage.toLowerCase(),
unread: Number(c.unreadCount) > 0,
};
}
export function sortThreads(list: SmsThread[]): SmsThread[] {
return [...list].sort((a, b) => b.lastTs - a.lastTs);
}
export function sortMessages(list: SMSMessage[]): SMSMessage[] {
return [...list].sort((a, b) => toTs(a.timestamp) - toTs(b.timestamp) || a.id - b.id);
}
export function filterThreads(list: SmsThread[], query: string): SmsThread[] {
const q = String(query || "").trim().toLowerCase();
if (!q) return list;
return list.filter((t) => t.peerLower.includes(q) || t.lastMessageLower.includes(q));
}
// Backend encodes direction numerically (1 = received, 2 = sent); fall back to direction text.
export function messageType(m: SMSMessage): number {
const t = Number(m.type);
if (t === 1 || t === 2) return t;
const d = String(m.direction || "").toLowerCase();
return d === "inbound" || d === "received" || d === "in" ? 1 : 2;
}
export type SmsDeliveryIndicator = "delivered" | "accepted_unconfirmed" | "failed" | "unknown";
// +CMGS and SIP 2xx prove next-hop acceptance, not recipient delivery.
export function messageDeliveryIndicator(m: SMSMessage): SmsDeliveryIndicator {
const delivery = String(m.deliveryState || "").toLowerCase();
const submission = String(m.status || "").toLowerCase();
if (delivery === "delivered" || delivery === "delivery_confirmed") return "delivered";
if (
delivery.includes("failed") || delivery.includes("rejected") ||
submission.includes("failed") || submission.includes("rejected") || submission.includes("partial")
) return "failed";
if (
delivery.includes("accepted") || submission.includes("accepted_by_modem") ||
submission.includes("accepted_by_ims")
) return "accepted_unconfirmed";
return "unknown";
}
export function messageBody(m: SMSMessage): string {
return String(m.content ?? m.body ?? "");
}
export interface MessageGroup {
date: string;
items: SMSMessage[];
}
export function groupMessagesByDay(list: SMSMessage[]): MessageGroup[] {
const groups: MessageGroup[] = [];
let current = "";
for (const m of list) {
const day = dayLabel(toTs(m.timestamp));
if (!groups.length || day !== current) {
groups.push({ date: day, items: [m] });
current = day;
} else {
groups[groups.length - 1].items.push(m);
}
}
return groups;
}