Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions chatplan.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,6 @@
</condition>
</extension>

<!-- Drop IMDN delivery notifications — Linphone sends these as SIP MESSAGEs
to confirm receipt. They should not route to the carrier API. -->
<extension name="imdn_filter">
<condition field="type" expression="^message/imdn">
<action application="info" data="IMDN notification dropped" inline="true"/>
</condition>
</extension>

<!-- All outbound: PSTN, short extensions, short codes -->
<extension name="outbound">
<condition field="to" expression="^(.+)$">
Expand Down
28 changes: 28 additions & 0 deletions outbound-hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@

require __DIR__."/providers/".$provider.".php";

$message_uuid = $dedupeID;

// Unwrap CPIM envelope by inner content type. Post-Fix-2f, Linphone wraps ALL
// outbound messages in CPIM — SMS, IMDN, typing indicators, and MMS alike.
// Dispatch on the inner type so the existing per-type cases still work.
if ($contentType === 'message/cpim') {
$cpim = CPIM::fromString($body);
$innerContentType = $cpim->getHeader('content-type');

// Drop notifications that have no carrier-side equivalent
if ($innerContentType && (
stripos($innerContentType, 'message/imdn') !== false ||
stripos($innerContentType, 'application/im-iscomposing') !== false
)) {
error_log("outbound-hook: dropping CPIM-wrapped ".$innerContentType);
http_response_code(200);
die();
}

// Unwrap text/plain so the existing text/plain case handles carrier SMS dispatch
if ($innerContentType && stripos($innerContentType, 'text/plain') !== false) {
$body = $cpim->body;
$contentType = 'text/plain';
}

// Otherwise (file-transfer XML): fall through to the message/cpim case
}

switch($contentType) {
case "text/plain":
$message_uuid = Messages::OutgoingSMS($extensionUUID, $domainUUID, $from, $to, $body, $message_uuid);
Expand Down
8 changes: 7 additions & 1 deletion providers/accelerate-networks.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ function incoming()
if ($body->{'MessageType'} == "0") {
$success = Messages::IncomingSMS($body->{'From'}, $body->{'To'}, $body->{'Content'});
} else {
$success = Messages::IncomingMMS($body->{'From'}, $body->{'To'}, $body->{'MediaURLs'}, $body->{'AdditionalRecipients'});
// `Content` is a comma-separated filename list aligned with `MediaURLs` by index
// (e.g. "part-003.txt,part-002.mp4,part-001.SMIL,"). Usually has a trailing comma.
$filenames = array_values(array_filter(
array_map('trim', explode(',', $body->{'Content'} ?? '')),
fn($f) => $f !== ''
));
$success = Messages::IncomingMMS($body->{'From'}, $body->{'To'}, $body->{'MediaURLs'}, $filenames, $body->{'AdditionalRecipients'});
}

if ($success) {
Expand Down
154 changes: 109 additions & 45 deletions src/CPIM.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
final class CPIM
{
public array $headers;
public string $body = '';
public string $filename;
public int $fileSize;
public $fileContentType;
Expand All @@ -22,6 +23,45 @@ public function __construct()
$this->headers = array();
}

/**
* Build a CPIM carrying an RCS file-transfer reference.
*
* @param string $filename display name of the attachment
* @param int $fileSize size of the attachment in bytes
* @param string $fileContentType MIME type of the attachment
* @param string $fileURL URL the receiving client will download from
*
* @return CPIM configured in file-transfer mode
*/
public static function forFileTransfer(
string $filename,
int $fileSize,
string $fileContentType,
string $fileURL
): self {
$cpim = new self();
$cpim->filename = $filename;
$cpim->fileSize = $fileSize;
$cpim->fileContentType = $fileContentType;
$cpim->fileURL = $fileURL;
return $cpim;
}

/**
* Build a CPIM carrying an inline text body.
*
* @param string $body plain text content
*
* @return CPIM configured in text-body mode
*/
public static function forText(string $body): self
{
$cpim = new self();
$cpim->body = $body;
$cpim->headers['Content-Type'] = 'text/plain';
return $cpim;
}

/**
* Parse a block of encoded headers
*
Expand Down Expand Up @@ -55,12 +95,22 @@ public static function fromString(string $raw): self
{
$message = new self();

$parts = explode("\n\n", $raw);
for($i = 0; $i < sizeof($parts)-2; $i++) {
$parts = explode("\n\n", str_replace("\r\n", "\n", $raw));
for($i = 0; $i < sizeof($parts)-1; $i++) {
$message->_addHeaders($parts[$i]);
}
$rawBody = $parts[sizeof($parts)-1];

$message->body = $rawBody;

// Linphone emits bare `&` in URL attribute values (technically-invalid XML).
// Pre-escape so xml_parse_into_struct can handle file-transfer payloads.
// Negative lookahead skips already-escaped entities to avoid double-encoding.
$rawBody = preg_replace(
'/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/',
'&amp;',
$rawBody
);

$parser = xml_parser_create();
xml_parse_into_struct($parser, $rawBody, $body);

Expand All @@ -70,7 +120,7 @@ public static function fromString(string $raw): self
switch($tag['tag']) {
case "FILE-SIZE":
$message->fileSize = (int)$tag['value'];
fileContentType;
break;
case "FILE-NAME":
$message->filename = $tag['value'];
break;
Expand All @@ -93,66 +143,80 @@ public static function fromString(string $raw): self
*/
public function toString(): string
{
$xw = xmlwriter_open_memory();
xmlwriter_set_indent($xw, true);
if (isset($this->fileURL)) {
$xw = xmlwriter_open_memory();
xmlwriter_set_indent($xw, true);

xmlwriter_start_document($xw, '1.0', 'UTF-8');
xmlwriter_start_document($xw, '1.0', 'UTF-8');

xmlwriter_start_element($xw, 'file');
xmlwriter_start_element($xw, 'file');

xmlwriter_start_attribute($xw, 'xmlns');
xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:fthttp');
xmlwriter_end_attribute($xw);
xmlwriter_start_attribute($xw, 'xmlns');
xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:fthttp');
xmlwriter_end_attribute($xw);

xmlwriter_start_attribute($xw, 'xmlns:am');
xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:rram');
xmlwriter_end_attribute($xw);
xmlwriter_start_attribute($xw, 'xmlns:am');
xmlwriter_text($xw, 'urn:gsma:params:xml:ns:rcs:rcs:rram');
xmlwriter_end_attribute($xw);

xmlwriter_start_element($xw, 'file-info');
xmlwriter_start_attribute($xw, 'type');
xmlwriter_text($xw, 'file');
xmlwriter_start_element($xw, 'file-info');
xmlwriter_write_attribute($xw, 'type', 'file');

if (isset($this->fileSize)) {
xmlwriter_start_attribute($xw, 'file-size');
xmlwriter_text($xw, (string)$this->fileSize);
xmlwriter_end_element($xw);
}
if (isset($this->fileSize)) {
xmlwriter_start_element($xw, 'file-size');
xmlwriter_text($xw, (string)$this->fileSize);
xmlwriter_end_element($xw);
}

if (isset($this->filename)) {
xmlwriter_start_element($xw, 'file-name');
xmlwriter_text($xw, $this->filename);
xmlwriter_end_element($xw);
}
if (isset($this->filename)) {
xmlwriter_start_element($xw, 'file-name');
xmlwriter_text($xw, $this->filename);
xmlwriter_end_element($xw);
}

if (isset($this->fileContentType)) {
xmlwriter_start_element($xw, 'content-type');
xmlwriter_text($xw, $this->fileContentType);
xmlwriter_end_element($xw);
}
if (isset($this->fileContentType)) {
xmlwriter_start_element($xw, 'content-type');
xmlwriter_text($xw, $this->fileContentType);
xmlwriter_end_element($xw);
}

if (isset($this->fileURL)) {
xmlwriter_start_element($xw, 'data');
xmlwriter_start_attribute($xw, 'url');
xmlwriter_text($xw, $this->fileURL);
xmlwriter_write_attribute($xw, 'url', $this->fileURL);
xmlwriter_end_element($xw);
}

xmlwriter_end_element($xw);
xmlwriter_end_element($xw); // close file-info

xmlwriter_end_element($xw);
xmlwriter_end_element($xw); // close file

$body = xmlwriter_output_memory($xw);
$body = xmlwriter_output_memory($xw);
$defaultContentType = 'application/vnd.gsma.rcs-ft-http+xml';
} else {
$body = $this->body;
$defaultContentType = 'text/plain';
}


if (!array_key_exists('content-length', $this->headers)) {
$this->headers['content-length'] = strlen($body);
// Normalize content header keys to Linphone-compatible case (uppercase T/L).
// Linphone's CPIM parser uses case-sensitive exact match for "Content-Type"
// and "Content-Length" (see cpim-message.cpp getContentHeader lookup).
foreach (['content-type' => 'Content-Type', 'content-length' => 'Content-Length', 'Content-type' => 'Content-Type', 'Content-length' => 'Content-Length'] as $variant => $proper) {
if (array_key_exists($variant, $this->headers)) {
$this->headers[$proper] = $this->headers[$variant];
if ($variant !== $proper) {
unset($this->headers[$variant]);
}
}
}

if (!array_key_exists('Content-Length', $this->headers)) {
$this->headers['Content-Length'] = strlen($body);
}

if (!array_key_exists('content-type', $this->headers)) {
$this->headers['content-type'] = "application/vnd.gsma.rcs-ft-http+xml";
if (!array_key_exists('Content-Type', $this->headers)) {
$this->headers['Content-Type'] = $defaultContentType;
}

$contentHeaders = array("content-type", "content-length");
$contentHeaders = array("Content-Type", "Content-Length");

$firstHeaderBlock = array();
foreach ($this->headers as $key=>$value) {
Expand All @@ -172,7 +236,7 @@ public function toString(): string
$secondHeaderBlock[] = $key.": ".$this->headers[$key];
}

$out = implode("\n", $firstHeaderBlock)."\n\n".implode("\n", $secondHeaderBlock)."\n\n".$body;
$out = implode("\r\n", $firstHeaderBlock)."\r\n\r\n".implode("\r\n", $secondHeaderBlock)."\r\n\r\n".$body;

return $out;
}
Expand Down
Loading
Loading