multi number sms suport

This commit is contained in:
2026-09-03 18:48:00 +05:00
parent c2a0846949
commit 02b0577ce2
3 changed files with 158 additions and 39 deletions
+88 -17
View File
@@ -242,6 +242,15 @@
z-index: 100; z-index: 100;
} }
.input-hint {
display: block;
margin-top: -8px;
margin-bottom: 12px;
font-size: 12px;
color: #888;
}
.modal { .modal {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 16px; border-radius: 16px;
@@ -315,7 +324,8 @@
<div class="modal-overlay" id="compose-modal" onclick="if(event.target===this)closeCompose()"> <div class="modal-overlay" id="compose-modal" onclick="if(event.target===this)closeCompose()">
<div class="modal"> <div class="modal">
<h2>New Message</h2> <h2>New Message</h2>
<input type="tel" id="compose-to" placeholder="Phone number"> <input type="text" id="compose-to" placeholder="Phone numbers, separated by commas" autocomplete="off">
<small class="input-hint"> Enter multiple phone numbers separated by commas </small>
<textarea id="compose-msg" placeholder="Message"></textarea> <textarea id="compose-msg" placeholder="Message"></textarea>
<p class="error-msg" id="compose-error"></p> <p class="error-msg" id="compose-error"></p>
<div class="modal-actions"> <div class="modal-actions">
@@ -495,27 +505,88 @@
} }
async function sendCompose() { async function sendCompose() {
const to = document.getElementById('compose-to').value.trim(); const toInput = document.getElementById('compose-to').value.trim();
const text = document.getElementById('compose-msg').value.trim(); const text = document.getElementById('compose-msg').value.trim();
if (!to || !text) { document.getElementById('compose-error').textContent = 'Fill all fields'; return; } const error = document.getElementById('compose-error');
error.textContent = '';
if (!toInput) {
error.textContent = 'Please enter at least one phone number';
return;
}
if (!text) {
error.textContent = 'Please enter a message';
return;
}
// Split numbers by comma
const recipients = toInput
.split(',')
.map(number => number.trim())
.filter(number => number.length > 0);
// Remove duplicate numbers
const uniqueRecipients = [...new Set(recipients)];
if (uniqueRecipients.length === 0) {
error.textContent = 'Please enter at least one phone number';
return;
}
// Basic phone number validation
const invalidNumber = uniqueRecipients.find(number => {
return !/^\+?[0-9\s\-()]+$/.test(number);
});
if (invalidNumber) {
error.textContent = `Invalid phone number: ${invalidNumber}`;
return;
}
const sendButton = document.querySelector(
'#compose-modal .modal-actions button:last-child'
);
sendButton.disabled = true;
sendButton.textContent = 'Sending...';
try { try {
const r = await fetch('/api/sms/send', { const response = await fetch('/api/sms/send', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, headers: {
body: JSON.stringify({ to, text }) 'Content-Type': 'application/json',
'X-API-Key': apiKey
},
body: JSON.stringify({
to: uniqueRecipients,
text: text
})
}); });
if (r.ok) {
closeCompose();
await loadMessages();
openThread(to);
} else {
const e = await r.json();
document.getElementById('compose-error').textContent = e.error || 'Failed';
}
} catch (e) { document.getElementById('compose-error').textContent = 'Error'; }
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send SMS');
}
closeCompose();
await loadMessages();
// Open the conversation when sending to one number
if (uniqueRecipients.length === 1) {
openThread(uniqueRecipients[0]);
}
} catch (err) {
error.textContent = err.message || 'Failed to send SMS';
} finally {
sendButton.disabled = false;
sendButton.textContent = 'Send';
}
}
function showAbout() { document.getElementById('about-modal').style.display = 'flex'; } function showAbout() { document.getElementById('about-modal').style.display = 'flex'; }
function closeAbout() { document.getElementById('about-modal').style.display = 'none'; } function closeAbout() { document.getElementById('about-modal').style.display = 'none'; }
@@ -531,4 +602,4 @@
setInterval(() => { if (apiKey) { loadMessages(); } }, 15000); setInterval(() => { if (apiKey) { loadMessages(); } }, 15000);
</script> </script>
</body> </body>
</html> </html
@@ -25,7 +25,11 @@ object StringOrListSerializer : KSerializer<List<String>> {
return when (val element = jsonDecoder.decodeJsonElement()) { return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonPrimitive -> listOf(element.content) is JsonPrimitive -> listOf(element.content)
is JsonArray -> element.map { it.jsonPrimitive.content }
is JsonArray -> element.map {
it.jsonPrimitive.content
}
else -> throw SerializationException( else -> throw SerializationException(
"'to' must be a phone number string or an array of phone numbers" "'to' must be a phone number string or an array of phone numbers"
) )
@@ -45,8 +49,6 @@ object StringOrListSerializer : KSerializer<List<String>> {
} }
} }
@Serializable @Serializable
data class SendSmsRequest( data class SendSmsRequest(
@Serializable(with = StringOrListSerializer::class) @Serializable(with = StringOrListSerializer::class)
@@ -7,6 +7,7 @@ import io.ktor.server.routing.*
import sh.sar.textpipe.data.model.ErrorResponse import sh.sar.textpipe.data.model.ErrorResponse
import sh.sar.textpipe.data.model.MessagesResponse import sh.sar.textpipe.data.model.MessagesResponse
import sh.sar.textpipe.data.model.SendSmsRequest import sh.sar.textpipe.data.model.SendSmsRequest
import sh.sar.textpipe.data.model.SendSmsResponse
import sh.sar.textpipe.data.repository.SmsRepository import sh.sar.textpipe.data.repository.SmsRepository
import sh.sar.textpipe.server.auth.ApiKeyAttributeKey import sh.sar.textpipe.server.auth.ApiKeyAttributeKey
import sh.sar.textpipe.server.auth.SimSlotAttributeKey import sh.sar.textpipe.server.auth.SimSlotAttributeKey
@@ -15,42 +16,87 @@ fun Route.smsRoutes(smsRepository: SmsRepository) {
post("/api/sms/send") { post("/api/sms/send") {
val apiKey = call.attributes.getOrNull(ApiKeyAttributeKey) val apiKey = call.attributes.getOrNull(ApiKeyAttributeKey)
if (apiKey == null) { if (apiKey == null) {
call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Missing API key")) call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse("Missing API key")
)
return@post return@post
} }
val request = try { val request = try {
call.receive<SendSmsRequest>() call.receive<SendSmsRequest>()
} catch (e: Exception) { } catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body: ${e.message}")) call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("Invalid request body: ${e.message}")
)
return@post return@post
} }
// Validate request // Validate recipients
if (request.to.isEmpty()) { if (request.to.isEmpty()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("'to' field is required")) call.respond(
return@post HttpStatusCode.BadRequest,
} ErrorResponse("'to' field is required")
)
return@post
}
// Remove empty numbers and duplicates
val recipients = request.to
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (recipients.isEmpty()) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("'to' must contain at least one phone number")
)
return@post
}
// Validate message
if (request.text.isBlank()) { if (request.text.isBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("'text' field is required")) call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("'text' field is required")
)
return@post return@post
} }
val responses = request.to.mapNotNull { number -> val responses = mutableListOf<SendSmsResponse>()
smsRepository.sendSms(number, request.text, apiKey) val failed = mutableListOf<String>()
}
if (responses.isEmpty()) { for (number in recipients) {
call.respond( val response = smsRepository.sendSms(
HttpStatusCode.InternalServerError, number,
ErrorResponse("Failed to send SMS") request.text,
) apiKey
return@post )
}
call.respond(HttpStatusCode.OK, responses) if (response != null) {
responses.add(response)
} else {
failed.add(number)
}
} }
// All failed
if (responses.isEmpty()) {
call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse("Failed to send SMS to all recipients")
)
return@post
}
// At least one succeeded
call.respond(
HttpStatusCode.OK,
responses
)
}
get("/api/sms/messages") { get("/api/sms/messages") {
val simSlot = call.attributes.getOrNull(SimSlotAttributeKey) val simSlot = call.attributes.getOrNull(SimSlotAttributeKey)
if (simSlot == null) { if (simSlot == null) {