mirror of https://github.com/pixelfed/pixelfed
commit
bb1e1bf7ba
@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\DirectMessage;
|
||||
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Services\SnowflakeService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillConversations extends Command
|
||||
{
|
||||
protected $signature = 'dm:backfill-conversations
|
||||
{--chunk=500 : Legacy rows to convert per batch}
|
||||
{--full : Start from the first legacy row instead of resuming}
|
||||
{--force : Do not ask for confirmation}';
|
||||
|
||||
protected $description = 'Convert legacy direct messages (direct_messages + direct statuses) into conversations';
|
||||
|
||||
/**
|
||||
* Nothing is removed: the legacy rows and their statuses stay where they
|
||||
* are. Each message keeps the id of the status it used to be, so it sorts
|
||||
* correctly against messages sent after the upgrade and the ids older
|
||||
* clients already hold keep working. Safe to stop and run again.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$total = DB::table('direct_messages')->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('No legacy direct messages to convert.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$start = $this->option('full') ? 0 : (int) DB::table('dm_messages')->max('legacy_dm_id');
|
||||
$remaining = DB::table('direct_messages')->where('id', '>', $start)->count();
|
||||
|
||||
if ($remaining === 0) {
|
||||
$this->info('Legacy direct messages are already converted.');
|
||||
$this->convertMutes();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Convert {$remaining} legacy direct messages?", true)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$chunk = max(50, (int) $this->option('chunk'));
|
||||
$bar = $this->output->createProgressBar($remaining);
|
||||
$bar->start();
|
||||
|
||||
DB::table('direct_messages')
|
||||
->where('id', '>', $start)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function (Collection $rows) use ($bar) {
|
||||
$this->convert($rows);
|
||||
$bar->advance($rows->count());
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
|
||||
$this->convertMutes();
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function convert(Collection $rows): void
|
||||
{
|
||||
$rows = $rows->filter(fn ($row) => $row->from_id && $row->to_id && $row->from_id != $row->to_id);
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$done = DB::table('dm_messages')->whereIn('legacy_dm_id', $rows->pluck('id'))->pluck('legacy_dm_id')->flip();
|
||||
$taken = DB::table('dm_messages')->whereIn('id', $rows->pluck('status_id'))->pluck('id')->flip();
|
||||
|
||||
$statuses = DB::table('statuses')
|
||||
->whereIn('id', $rows->pluck('status_id'))
|
||||
->whereNull('deleted_at')
|
||||
->get(['id', 'profile_id', 'caption', 'is_nsfw', 'uri', 'object_url', 'url', 'created_at'])
|
||||
->keyBy('id');
|
||||
|
||||
$profiles = DB::table('profiles')
|
||||
->whereIn('id', $rows->pluck('from_id')->merge($rows->pluck('to_id'))->unique())
|
||||
->get(['id', 'username', 'domain'])
|
||||
->keyBy('id');
|
||||
|
||||
$media = DB::table('media')
|
||||
->whereIn('status_id', $statuses->keys())
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('order')
|
||||
->get(['id', 'status_id'])
|
||||
->groupBy('status_id');
|
||||
|
||||
$touched = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = $statuses->get($row->status_id);
|
||||
$from = $profiles->get($row->from_id);
|
||||
$to = $profiles->get($row->to_id);
|
||||
|
||||
if (! $status || ! $from || ! $to || $done->has($row->id) || $taken->has($row->status_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conversationId = $this->conversation($row);
|
||||
$touched[$conversationId] = [(int) $row->from_id, (int) $row->to_id];
|
||||
|
||||
$body = $status->caption;
|
||||
|
||||
if ($body !== null && $from->domain !== null) {
|
||||
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
$uri = $from->domain !== null
|
||||
? ($status->uri ?: ($status->object_url ?: $status->url))
|
||||
: url(config('app.url').'/p/'.$from->username.'/'.$status->id);
|
||||
|
||||
$inserted = DB::table('dm_messages')->insertOrIgnore([
|
||||
'id' => $status->id,
|
||||
'conversation_id' => $conversationId,
|
||||
'profile_id' => $row->from_id,
|
||||
'type' => $row->type ?: DmMessage::TYPE_TEXT,
|
||||
'body' => $body === '' ? null : $body,
|
||||
'meta' => $this->meta($row->meta),
|
||||
'is_sensitive' => (bool) $status->is_nsfw,
|
||||
'ap_object_uri' => $uri,
|
||||
'ap_object_hash' => $uri ? DmMessage::hashUri($uri) : null,
|
||||
'status_id' => $status->id,
|
||||
'legacy_dm_id' => $row->id,
|
||||
'created_at' => $row->created_at ?? $status->created_at,
|
||||
'updated_at' => $row->updated_at ?? $row->created_at ?? $status->created_at,
|
||||
]);
|
||||
|
||||
if (! $inserted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$taken->put($status->id, true);
|
||||
|
||||
foreach ($media->get($status->id, collect())->values() as $position => $item) {
|
||||
DB::table('dm_message_media')->insertOrIgnore([
|
||||
'message_id' => $status->id,
|
||||
'media_id' => $item->id,
|
||||
'position' => $position,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($touched as $conversationId => $pair) {
|
||||
$this->refresh($conversationId, $pair);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation for a legacy row, created on first sight. A legacy
|
||||
* message that was filtered (is_hidden) means its recipient had not
|
||||
* accepted the sender yet, which is what a request is now.
|
||||
*/
|
||||
protected function conversation(object $row): int
|
||||
{
|
||||
$hash = DmConversation::dmHash((int) $row->from_id, (int) $row->to_id);
|
||||
$id = DB::table('dm_conversations')->where('participants_hash', $hash)->value('id');
|
||||
|
||||
if (! $id) {
|
||||
$id = SnowflakeService::next();
|
||||
$now = now();
|
||||
|
||||
DB::table('dm_conversations')->insertOrIgnore([
|
||||
'id' => $id,
|
||||
'type' => DmConversation::TYPE_DM,
|
||||
'participants_hash' => $hash,
|
||||
'context_uri' => DmConversation::localContextUri($id),
|
||||
'created_by_profile_id' => $row->from_id,
|
||||
'created_at' => $row->created_at ?? $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$id = (int) DB::table('dm_conversations')->where('participants_hash', $hash)->value('id');
|
||||
|
||||
foreach ([(int) $row->from_id, (int) $row->to_id] as $profileId) {
|
||||
DB::table('dm_conversation_participants')->insertOrIgnore([
|
||||
'conversation_id' => $id,
|
||||
'profile_id' => $profileId,
|
||||
'state' => $profileId === (int) $row->to_id && $row->is_hidden
|
||||
? DmConversationParticipant::STATE_REQUEST
|
||||
: DmConversationParticipant::STATE_ACTIVE,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
// Writing back, or getting a message through unfiltered, settles a request
|
||||
DB::table('dm_conversation_participants')
|
||||
->where('conversation_id', $id)
|
||||
->where('state', DmConversationParticipant::STATE_REQUEST)
|
||||
->where(function ($query) use ($row) {
|
||||
$query->where('profile_id', $row->from_id);
|
||||
|
||||
if (! $row->is_hidden) {
|
||||
$query->orWhere('profile_id', $row->to_id);
|
||||
}
|
||||
})
|
||||
->update(['state' => DmConversationParticipant::STATE_ACTIVE]);
|
||||
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the conversation pointers and both read states in line with what
|
||||
* has been converted so far.
|
||||
*
|
||||
* @param array{0: int, 1: int} $pair
|
||||
*/
|
||||
protected function refresh(int $conversationId, array $pair): void
|
||||
{
|
||||
$last = DB::table('dm_messages')
|
||||
->where('conversation_id', $conversationId)
|
||||
->whereNull('deleted_at')
|
||||
->orderByDesc('id')
|
||||
->first(['id', 'created_at']);
|
||||
|
||||
if (! $last) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('dm_conversations')
|
||||
->where('id', $conversationId)
|
||||
->where(function ($query) use ($last) {
|
||||
$query->whereNull('last_message_id')->orWhere('last_message_id', '<', $last->id);
|
||||
})
|
||||
->update(['last_message_id' => $last->id, 'last_message_at' => $last->created_at]);
|
||||
|
||||
foreach ([[$pair[0], $pair[1]], [$pair[1], $pair[0]]] as [$me, $other]) {
|
||||
$unread = DB::table('direct_messages')
|
||||
->where('to_id', $me)
|
||||
->where('from_id', $other)
|
||||
->whereNull('read_at')
|
||||
->whereIn('status_id', DB::table('dm_messages')->where('conversation_id', $conversationId)->select('id'))
|
||||
->count();
|
||||
|
||||
$lastRead = $unread === 0
|
||||
? $last->id
|
||||
: DB::table('direct_messages')
|
||||
->where('to_id', $me)
|
||||
->where('from_id', $other)
|
||||
->whereNotNull('read_at')
|
||||
->max('status_id');
|
||||
|
||||
DB::table('dm_conversation_participants')
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('profile_id', $me)
|
||||
->update([
|
||||
'unread_count' => $unread,
|
||||
'last_read_message_id' => $lastRead,
|
||||
'last_activity_at' => $last->created_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muting a thread used to be a `dm.mute` user filter.
|
||||
*/
|
||||
protected function convertMutes(): void
|
||||
{
|
||||
DB::table('user_filters')
|
||||
->where('filter_type', 'dm.mute')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function (Collection $filters) {
|
||||
foreach ($filters as $filter) {
|
||||
$id = DB::table('dm_conversations')
|
||||
->where('participants_hash', DmConversation::dmHash((int) $filter->user_id, (int) $filter->filterable_id))
|
||||
->value('id');
|
||||
|
||||
if (! $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('dm_conversation_participants')
|
||||
->where('conversation_id', $id)
|
||||
->where('profile_id', $filter->user_id)
|
||||
->whereNull('muted_at')
|
||||
->update(['muted_at' => $filter->created_at ?? now()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function meta(mixed $meta): ?string
|
||||
{
|
||||
if ($meta === null || $meta === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = is_string($meta) ? json_decode($meta, true) : $meta;
|
||||
|
||||
// Older rows were json encoded twice
|
||||
if (is_string($decoded)) {
|
||||
$decoded = json_decode($decoded, true);
|
||||
}
|
||||
|
||||
return is_array($decoded) ? json_encode($decoded) : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* A direct message action the user is not allowed to take, or asked for in a
|
||||
* way that cannot work. The message is safe to show and the code is the HTTP
|
||||
* status it renders as.
|
||||
*/
|
||||
class DirectMessageException extends Exception
|
||||
{
|
||||
public function __construct(string $message, protected int $status = 422)
|
||||
{
|
||||
parent::__construct($message, $status);
|
||||
}
|
||||
|
||||
public function status(): int
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function render(): JsonResponse
|
||||
{
|
||||
return response()->json(['error' => $this->getMessage()], $this->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected user errors, not worth a log line.
|
||||
*/
|
||||
public function report(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Federation\ActivityBuilders;
|
||||
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Profile;
|
||||
use App\Services\DirectMessagePayloadService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DirectMessageActivityBuilder
|
||||
{
|
||||
public const CONTEXT = [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
[
|
||||
'ostatus' => 'http://ostatus.org#',
|
||||
'conversation' => 'ostatus:conversation',
|
||||
'sensitive' => 'as:sensitive',
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'blurhash' => 'toot:blurhash',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param Collection<int, Profile> $others Everyone in the conversation except the sender
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildCreate(DmMessage $message, DmConversation $conversation, Profile $sender, Collection $others): array
|
||||
{
|
||||
$note = $this->buildNote($message, $conversation, $sender, $others);
|
||||
|
||||
return [
|
||||
'@context' => self::CONTEXT,
|
||||
'id' => $note['id'].'/activity',
|
||||
'type' => 'Create',
|
||||
'actor' => $note['attributedTo'],
|
||||
'published' => $note['published'],
|
||||
'to' => $note['to'],
|
||||
'cc' => [],
|
||||
'object' => $note,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Three things make this thread on Mastodon and show up under
|
||||
* Conversations rather than as a stray private post:
|
||||
*
|
||||
* - every addressee in `to` also has a Mention tag, otherwise the status
|
||||
* is filed as "limited" and never reaches the DM view
|
||||
* - `inReplyTo` points at the previous message, so the receiving server
|
||||
* puts it in the same conversation as its parent
|
||||
* - `context` and `conversation` repeat what the thread is known by, for
|
||||
* servers that never saw the parent
|
||||
*
|
||||
* @param Collection<int, Profile> $others
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildNote(DmMessage $message, DmConversation $conversation, Profile $sender, Collection $others): array
|
||||
{
|
||||
$recipients = $others->filter(fn (Profile $profile) => $profile->status === null)->values();
|
||||
$uri = $message->objectUri();
|
||||
|
||||
return [
|
||||
'id' => $uri,
|
||||
'type' => 'Note',
|
||||
'summary' => null,
|
||||
'content' => DirectMessagePayloadService::renderHtml($message->body),
|
||||
'inReplyTo' => $this->inReplyTo($message),
|
||||
'published' => $message->created_at->toAtomString(),
|
||||
'url' => $uri,
|
||||
'attributedTo' => $sender->permalink(),
|
||||
'to' => $recipients->map(fn (Profile $profile) => $profile->permalink())->values()->all(),
|
||||
'cc' => [],
|
||||
'sensitive' => (bool) $message->is_sensitive,
|
||||
'context' => $conversation->contextUri(),
|
||||
'conversation' => $conversation->conversationUri(),
|
||||
'attachment' => $message->media()->get()->map(fn (Media $media) => $this->attachment($media))->values()->all(),
|
||||
'tag' => $recipients->map(fn (Profile $profile) => [
|
||||
'type' => 'Mention',
|
||||
'href' => $profile->permalink(),
|
||||
'name' => $this->mentionName($profile),
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Profile> $others
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildDelete(DmMessage $message, Profile $sender, Collection $others): array
|
||||
{
|
||||
$uri = $message->objectUri();
|
||||
|
||||
return [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $uri.'#delete',
|
||||
'type' => 'Delete',
|
||||
'actor' => $sender->permalink(),
|
||||
'to' => $others
|
||||
->filter(fn (Profile $profile) => $profile->status === null)
|
||||
->map(fn (Profile $profile) => $profile->permalink())
|
||||
->values()
|
||||
->all(),
|
||||
'object' => [
|
||||
'id' => $uri,
|
||||
'type' => 'Tombstone',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The message being answered, or failing that the one right before this
|
||||
* one, so the thread is a chain remote servers can follow.
|
||||
*/
|
||||
protected function inReplyTo(DmMessage $message): ?string
|
||||
{
|
||||
if ($message->in_reply_to_id) {
|
||||
$parent = DmMessage::find($message->in_reply_to_id);
|
||||
|
||||
if ($parent) {
|
||||
return $parent->objectUri();
|
||||
}
|
||||
}
|
||||
|
||||
$previous = DmMessage::where('conversation_id', $message->conversation_id)
|
||||
->where('id', '<', $message->id)
|
||||
->whereNotIn('type', [DmMessage::TYPE_STORY_REACT, DmMessage::TYPE_STORY_COMMENT])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $previous?->objectUri();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function attachment(Media $media): array
|
||||
{
|
||||
$attachment = [
|
||||
'type' => $media->activityVerb(),
|
||||
'mediaType' => $media->mime === 'image/jpg' ? 'image/jpeg' : $media->mime,
|
||||
'url' => $media->url(),
|
||||
'name' => $media->caption,
|
||||
];
|
||||
|
||||
if ($media->blurhash) {
|
||||
$attachment['blurhash'] = $media->blurhash;
|
||||
}
|
||||
|
||||
if ($media->width && $media->height) {
|
||||
$attachment['width'] = (int) $media->width;
|
||||
$attachment['height'] = (int) $media->height;
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
protected function mentionName(Profile $profile): string
|
||||
{
|
||||
if ($profile->domain === null) {
|
||||
return '@'.$profile->username.'@'.parse_url(config('app.url'), PHP_URL_HOST);
|
||||
}
|
||||
|
||||
return str_starts_with($profile->username, '@') ? $profile->username : '@'.$profile->username;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace App\Federation\Handlers;
|
||||
|
||||
use App\Exceptions\DirectMessageException;
|
||||
use App\Federation\Validators\DirectMessageValidator;
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Status;
|
||||
use App\Services\DirectMessageService;
|
||||
use App\Services\FollowersSyncService;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
use Throwable;
|
||||
|
||||
class DirectMessageHandler
|
||||
{
|
||||
public const MAX_BODY_LENGTH = 5000;
|
||||
|
||||
public function __construct(protected DirectMessageService $service) {}
|
||||
|
||||
/**
|
||||
* Store a direct Note delivered by $actor.
|
||||
*
|
||||
* The conversation is whoever the Note is addressed to plus its author,
|
||||
* so one recipient is a 1:1 chat and several are a group. Returns null
|
||||
* when the message is dropped, which is never an error: the sender is
|
||||
* not told about blocks, privacy settings or limits.
|
||||
*/
|
||||
public function handleCreate(array $object, Profile $actor): ?DmMessage
|
||||
{
|
||||
if ($actor->domain === null || $actor->status !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! DirectMessageValidator::validate($object)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = Helpers::pluckval($object['id']);
|
||||
|
||||
if ($this->alreadyStored($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$others = $this->resolveParticipants($object, $actor);
|
||||
|
||||
if ($others === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$locals = $others->filter(fn (Profile $profile) => $profile->domain === null);
|
||||
|
||||
// A recipient who blocks the sender never sees the message. When that
|
||||
// leaves nobody here to read it there is nothing to store.
|
||||
$readers = $locals->reject(fn (Profile $profile) => $this->service->isBlockedBy($profile, $actor));
|
||||
|
||||
if ($readers->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$existing = DmConversation::where(
|
||||
'participants_hash',
|
||||
DmConversation::participantsHash($others->pluck('id')->push($actor->id)->all())
|
||||
)->first();
|
||||
|
||||
if ($existing) {
|
||||
// Someone who left the conversation is no longer reading it
|
||||
$left = DmConversationParticipant::where('conversation_id', $existing->id)
|
||||
->where('state', DmConversationParticipant::STATE_LEFT)
|
||||
->pluck('profile_id')
|
||||
->all();
|
||||
|
||||
$readers = $readers->reject(fn (Profile $profile) => in_array($profile->id, $left));
|
||||
|
||||
if ($readers->isEmpty() || $this->requestLimitReached($existing, $actor, $readers)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$body = self::plainText($object['content'] ?? null);
|
||||
$media = $this->storeAttachments($object, $actor);
|
||||
|
||||
if ($body === null && $media->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$conversation = $existing ?? $this->service->findOrCreateConversation($actor, $others, [
|
||||
'context_uri' => $this->uri($object['context'] ?? null),
|
||||
'conversation_uri' => $this->uri($object['conversation'] ?? null),
|
||||
]);
|
||||
} catch (DirectMessageException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->service->adoptContext(
|
||||
$conversation,
|
||||
$this->uri($object['context'] ?? null),
|
||||
$this->uri($object['conversation'] ?? null)
|
||||
);
|
||||
|
||||
try {
|
||||
return $this->service->storeMessage($conversation, $actor, [
|
||||
'body' => $body,
|
||||
'media' => $media,
|
||||
'ap_object_uri' => $id,
|
||||
'in_reply_to_id' => $this->parentId($conversation, $object['inReplyTo'] ?? null),
|
||||
'is_sensitive' => (bool) ($object['sensitive'] ?? false),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Most likely the same delivery processed twice at once
|
||||
Log::info('DirectMessageHandler: message not stored', [
|
||||
'id' => $id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An Update for a message $actor wrote: the text changed. Returns true
|
||||
* when the object was a direct message.
|
||||
*/
|
||||
public function handleUpdate(array $object, Profile $actor): bool
|
||||
{
|
||||
$id = Helpers::pluckval($object['id'] ?? null);
|
||||
|
||||
if (! is_string($id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = DmMessage::whereObjectUri($id)->first();
|
||||
|
||||
if (! $message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $message->profile_id !== (int) $actor->id || ! DirectMessageValidator::isDirect($object, $actor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($object['content']) && is_string($object['content'])) {
|
||||
$body = self::plainText($object['content']);
|
||||
|
||||
if ($body !== $message->body) {
|
||||
$message->body = $body;
|
||||
$message->edited_at = now();
|
||||
$message->save();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Delete for a message $actor wrote. Returns true when the object was a
|
||||
* direct message, so the caller can stop looking for a status.
|
||||
*/
|
||||
public function handleDelete(Profile $actor, string $objectId): bool
|
||||
{
|
||||
$message = DmMessage::whereObjectUri($objectId)->first();
|
||||
|
||||
if (! $message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $message->profile_id === (int) $actor->id) {
|
||||
$this->service->deleteMessage($message, false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages from before the refactor live in the statuses table, and a
|
||||
* redelivery of one of those must not come back as a new message.
|
||||
*/
|
||||
protected function alreadyStored(string $id): bool
|
||||
{
|
||||
if (DmMessage::withTrashed()->whereObjectUri($id)->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Status::withTrashed()->where('uri', $id)->exists()
|
||||
|| Status::withTrashed()->where('object_url', $id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone the Note is addressed to, other than its author. Null means
|
||||
* the message cannot be a conversation here: nobody local is addressed,
|
||||
* or more people than a group allows.
|
||||
*
|
||||
* Addressees that do not resolve to an actor are left out. Everyone who
|
||||
* does resolve was named by the sender, so the message still only reaches
|
||||
* people it was meant for.
|
||||
*
|
||||
* @return Collection<int, Profile>|null
|
||||
*/
|
||||
protected function resolveParticipants(array $object, Profile $actor): ?Collection
|
||||
{
|
||||
$audience = array_values(array_filter(
|
||||
DirectMessageValidator::audience($object),
|
||||
fn (string $uri) => $uri !== $actor->remote_url
|
||||
));
|
||||
|
||||
if (empty($audience)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($audience) + 1 > (int) config('dm.groups.max_participants')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($audience) > 1 && ! config('dm.groups.enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fetches = 0;
|
||||
$maxFetches = (int) config('dm.federation.max_actor_fetches');
|
||||
$resolved = collect();
|
||||
|
||||
foreach ($audience as $uri) {
|
||||
$host = parse_url($uri, PHP_URL_HOST);
|
||||
|
||||
if (! is_string($host)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Helpers::isLocalDomain($host)) {
|
||||
$local = FollowersSyncService::resolveLocalActor($uri);
|
||||
$profile = $local ? Profile::find($local->id) : null;
|
||||
|
||||
if ($profile && $profile->status === null && $profile->user_id) {
|
||||
$resolved->put($profile->id, $profile);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$profile = Profile::whereRemoteUrl($uri)->first();
|
||||
|
||||
if (! $profile && $fetches < $maxFetches && Helpers::validateUrl($uri)) {
|
||||
$fetches++;
|
||||
|
||||
try {
|
||||
$profile = Helpers::profileFetch($uri);
|
||||
} catch (Throwable) {
|
||||
$profile = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($profile && $profile->status === null && $profile->id !== $actor->id) {
|
||||
$resolved->put($profile->id, $profile);
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolved->filter(fn (Profile $profile) => $profile->domain === null)->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $resolved->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* While every reader still has the conversation as a request, only so
|
||||
* many messages from this sender are kept.
|
||||
*
|
||||
* @param Collection<int, Profile> $readers
|
||||
*/
|
||||
protected function requestLimitReached(DmConversation $conversation, Profile $actor, Collection $readers): bool
|
||||
{
|
||||
$accepted = DmConversationParticipant::where('conversation_id', $conversation->id)
|
||||
->whereIn('profile_id', $readers->pluck('id'))
|
||||
->where('state', DmConversationParticipant::STATE_ACTIVE)
|
||||
->exists();
|
||||
|
||||
if ($accepted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored = DmMessage::where('conversation_id', $conversation->id)
|
||||
->where('profile_id', $actor->id)
|
||||
->count();
|
||||
|
||||
return $stored >= (int) config('dm.requests.inbound_limit');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Media>
|
||||
*/
|
||||
protected function storeAttachments(array $object, Profile $actor): Collection
|
||||
{
|
||||
$attachments = $object['attachment'] ?? [];
|
||||
|
||||
if (! is_array($attachments) || empty($attachments)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
if (! array_is_list($attachments)) {
|
||||
$attachments = [$attachments];
|
||||
}
|
||||
|
||||
$allowed = explode(',', (string) config_cache('pixelfed.media_types'));
|
||||
$stored = collect();
|
||||
|
||||
foreach (array_slice($attachments, 0, (int) config('dm.max_media')) as $attachment) {
|
||||
if (! is_array($attachment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mime = $attachment['mediaType'] ?? null;
|
||||
$url = $attachment['url'] ?? null;
|
||||
|
||||
if (is_array($url)) {
|
||||
$url = $url['href'] ?? ($url[0]['href'] ?? null);
|
||||
}
|
||||
|
||||
if (! is_string($mime) || ! in_array($mime, $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($url) || strlen($url) > 255 || ! Helpers::validateUrl($url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$media = new Media;
|
||||
$media->remote_media = true;
|
||||
$media->status_id = null;
|
||||
$media->profile_id = $actor->id;
|
||||
$media->user_id = null;
|
||||
$media->media_path = $url;
|
||||
$media->remote_url = $url;
|
||||
$media->mime = $mime;
|
||||
$media->version = 3;
|
||||
$media->order = $stored->count() + 1;
|
||||
$media->is_nsfw = (bool) ($object['sensitive'] ?? false);
|
||||
$media->blurhash = is_string($attachment['blurhash'] ?? null) ? $attachment['blurhash'] : null;
|
||||
$media->caption = is_string($attachment['name'] ?? null)
|
||||
? mb_substr(Purify::clean($attachment['name']), 0, 1000)
|
||||
: null;
|
||||
|
||||
if (is_numeric($attachment['width'] ?? null) && is_numeric($attachment['height'] ?? null)) {
|
||||
$media->width = (int) $attachment['width'];
|
||||
$media->height = (int) $attachment['height'];
|
||||
}
|
||||
|
||||
try {
|
||||
$media->save();
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stored->push($media);
|
||||
}
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
protected function parentId(DmConversation $conversation, mixed $inReplyTo): ?int
|
||||
{
|
||||
$uri = $this->uri($inReplyTo);
|
||||
|
||||
if (! $uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = DmMessage::whereObjectUri($uri)
|
||||
->where('conversation_id', $conversation->id)
|
||||
->value('id');
|
||||
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A property that should be an id: a string, or an object carrying one.
|
||||
*/
|
||||
protected function uri(mixed $value): ?string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = $value['id'] ?? (array_is_list($value) ? ($value[0] ?? null) : null);
|
||||
}
|
||||
|
||||
if (! is_string($value) || $value === '' || strlen($value) > 1024) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages are kept as plain text. Paragraphs and line breaks survive,
|
||||
* markup does not, and the mentions Mastodon puts in front of every
|
||||
* direct message are dropped because the participants already say who it
|
||||
* is for.
|
||||
*/
|
||||
public static function plainText(?string $html): ?string
|
||||
{
|
||||
if ($html === null || trim($html) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$html = preg_replace('/<br\s*\/?>/i', "\n", $html);
|
||||
$html = preg_replace('/<\/p>\s*<p[^>]*>/i', "\n\n", $html);
|
||||
|
||||
$text = strip_tags(Purify::clean($html));
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = str_replace("\u{00A0}", ' ', $text);
|
||||
|
||||
$text = preg_replace('/^\s*(?:@[\p{L}\p{N}_.\-]+(?:@[\p{L}\p{N}_.\-]+)?[\s,:]*)+/u', '', $text);
|
||||
|
||||
$text = preg_replace('/[ \t]+/', ' ', $text);
|
||||
$text = preg_replace('/ ?\n ?/', "\n", $text);
|
||||
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
||||
$text = trim($text);
|
||||
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_substr($text, 0, self::MAX_BODY_LENGTH);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Federation\Validators;
|
||||
|
||||
use App\Models\Profile;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
|
||||
class DirectMessageValidator
|
||||
{
|
||||
public const PUBLIC_URIS = [
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
'as:Public',
|
||||
'Public',
|
||||
];
|
||||
|
||||
/**
|
||||
* Everyone the object is addressed to, as a flat list of ids.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function audience(array $object): array
|
||||
{
|
||||
$audience = [];
|
||||
|
||||
foreach (['to', 'cc'] as $field) {
|
||||
$value = $object[$field] ?? [];
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = [$value];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($value as $entry) {
|
||||
if (is_array($entry)) {
|
||||
$entry = $entry['id'] ?? null;
|
||||
}
|
||||
|
||||
if (is_string($entry) && $entry !== '') {
|
||||
$audience[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($audience));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Note is direct when it is addressed to people and nothing else: not
|
||||
* the public collection and not anyone's followers.
|
||||
*
|
||||
* This has to be decided before a Note is considered as a post or a
|
||||
* reply. Those paths store anything that is not public as followers-only,
|
||||
* which would show a private message to the sender's local followers.
|
||||
*/
|
||||
public static function isDirect(array $object, Profile $actor): bool
|
||||
{
|
||||
if (($object['type'] ?? null) !== 'Note') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$audience = self::audience($object);
|
||||
|
||||
if (empty($audience)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($audience as $uri) {
|
||||
if (in_array($uri, self::PUBLIC_URIS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::isFollowersCollection($uri, $actor)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Followers collections do not share a URL shape across software, but
|
||||
* every known implementation has a /followers segment in it.
|
||||
*/
|
||||
public static function isFollowersCollection(string $uri, Profile $actor): bool
|
||||
{
|
||||
if ($actor->followers_url && $uri === $actor->followers_url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains(strtolower($uri), '/followers');
|
||||
}
|
||||
|
||||
/**
|
||||
* The object itself is well formed. Whether the sender is allowed to
|
||||
* speak for it is checked by the inbox before this runs.
|
||||
*/
|
||||
public static function validate(array $object): bool
|
||||
{
|
||||
$id = Helpers::pluckval($object['id'] ?? null);
|
||||
|
||||
if (! is_string($id) || strlen($id) > 1024 || ! Helpers::validateUrl($id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($object['content']) && ! is_string($object['content'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($object['attachment']) && ! is_array($object['attachment'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasContent = isset($object['content']) && trim(strip_tags($object['content'])) !== '';
|
||||
$hasAttachment = ! empty($object['attachment']);
|
||||
|
||||
if (! $hasContent && ! $hasAttachment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($object['published'])) {
|
||||
$published = Helpers::pluckval($object['published']);
|
||||
|
||||
if (! is_string($published) || ! Helpers::validateTimestamp($published)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\DirectMessageException;
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Profile;
|
||||
use App\Services\DirectMessagePayloadService;
|
||||
use App\Services\DirectMessageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DirectConversationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected DirectMessageService $service,
|
||||
protected DirectMessagePayloadService $payloads
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1.1/direct/conversations
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'read');
|
||||
|
||||
$this->validate($request, [
|
||||
'filter' => 'sometimes|string|in:primary,requests,hidden',
|
||||
'limit' => 'sometimes|integer|min:1|max:40',
|
||||
'cursor' => 'sometimes|nullable|string',
|
||||
]);
|
||||
|
||||
$pid = $request->user()->profile_id;
|
||||
$filter = $request->input('filter', 'primary');
|
||||
|
||||
$page = $this->inboxQuery($pid, $filter)
|
||||
->with('conversation')
|
||||
->cursorPaginate((int) $request->input('limit', 20));
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->payloads->conversations(collect($page->items()), $pid),
|
||||
'meta' => [
|
||||
'filter' => $filter,
|
||||
'next_cursor' => $page->nextCursor()?->encode(),
|
||||
'prev_cursor' => $page->previousCursor()?->encode(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1.1/direct/unread_count
|
||||
*/
|
||||
public function unreadCount(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'read');
|
||||
|
||||
$pid = $request->user()->profile_id;
|
||||
|
||||
return response()->json([
|
||||
'primary' => (int) $this->inboxQuery($pid, 'primary')
|
||||
->whereNull('muted_at')
|
||||
->where('unread_count', '>', 0)
|
||||
->count(),
|
||||
'requests' => (int) $this->inboxQuery($pid, 'requests')->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1.1/direct/conversations
|
||||
*
|
||||
* Find the conversation with these people, or start it. One recipient is
|
||||
* a 1:1 chat, several are a group.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
$this->validate($request, [
|
||||
'recipient_ids' => 'required|array|min:1|max:'.max(1, (int) config('dm.groups.max_participants') - 1),
|
||||
'recipient_ids.*' => 'required|integer|min:1|distinct',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$sender = $user->profile;
|
||||
|
||||
$ids = collect($request->input('recipient_ids'))->map(fn ($id) => (int) $id)->reject(fn ($id) => $id === $sender->id);
|
||||
$recipients = Profile::whereIn('id', $ids)->whereNull('status')->get();
|
||||
|
||||
if ($recipients->isEmpty() || $recipients->count() !== $ids->count()) {
|
||||
throw new DirectMessageException('One or more recipients could not be found.', 404);
|
||||
}
|
||||
|
||||
$hash = DmConversation::participantsHash($recipients->pluck('id')->push($sender->id)->all());
|
||||
$exists = DmConversation::where('participants_hash', $hash)->exists();
|
||||
|
||||
if (! $exists) {
|
||||
if (! $this->service->canInitiateConversation($user)) {
|
||||
throw new DirectMessageException('You need to wait a bit before you can DM another account', 400);
|
||||
}
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
if ($recipients->count() === 1 && ! $this->service->canMessage($sender, $recipient)) {
|
||||
throw new DirectMessageException('You cannot message this account.', 403);
|
||||
}
|
||||
|
||||
// In a group, someone who blocks the sender is still added and
|
||||
// simply never sees their messages. The sender blocking one
|
||||
// of their own picks is a mistake worth pointing out.
|
||||
if ($recipients->count() > 1 && $this->service->isBlockedBy($sender, $recipient)) {
|
||||
throw new DirectMessageException('You have blocked one of these accounts.', 422);
|
||||
}
|
||||
|
||||
if ($recipient->domain !== null && ! config('federation.activitypub.enabled')) {
|
||||
throw new DirectMessageException('You cannot message this account.', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$conversation = $this->service->findOrCreateConversation($sender, $recipients);
|
||||
$participant = $this->service->participant($conversation, $sender->id);
|
||||
|
||||
if ($participant->hasLeft()) {
|
||||
$participant->state = DmConversationParticipant::STATE_ACTIVE;
|
||||
$participant->save();
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$this->payloads->conversation($conversation, $participant, null, $this->lastVisibleMessage($conversation, $sender->id), $sender->id),
|
||||
$exists ? 200 : 201
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1.1/direct/conversations/{id}
|
||||
*/
|
||||
public function show(Request $request, $id): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'read');
|
||||
|
||||
[$conversation, $participant] = $this->resolve($request, $id);
|
||||
$pid = $request->user()->profile_id;
|
||||
|
||||
return response()->json(
|
||||
$this->payloads->conversation($conversation, $participant, null, $this->lastVisibleMessage($conversation, $pid), $pid)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1.1/direct/conversations/{id}/messages
|
||||
*
|
||||
* Newest first. `max_id` pages back in time, `min_id` returns what
|
||||
* arrived since, which is what a client polls with.
|
||||
*/
|
||||
public function messages(Request $request, $id): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'read');
|
||||
|
||||
$this->validate($request, [
|
||||
'limit' => 'sometimes|integer|min:1|max:50',
|
||||
'max_id' => 'sometimes|integer|min:1',
|
||||
'min_id' => 'sometimes|integer|min:1',
|
||||
]);
|
||||
|
||||
[$conversation] = $this->resolve($request, $id);
|
||||
|
||||
$pid = $request->user()->profile_id;
|
||||
$limit = (int) $request->input('limit', 20);
|
||||
|
||||
$query = DmMessage::with('media')
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNotIn('profile_id', $this->payloads->blockedIds($pid) ?: [0]);
|
||||
|
||||
if ($request->filled('min_id')) {
|
||||
$messages = $query->where('id', '>', $request->input('min_id'))
|
||||
->orderBy('id')
|
||||
->limit($limit + 1)
|
||||
->get();
|
||||
|
||||
$hasMore = $messages->count() > $limit;
|
||||
$messages = $messages->take($limit)->reverse()->values();
|
||||
} else {
|
||||
if ($request->filled('max_id')) {
|
||||
$query->where('id', '<', $request->input('max_id'));
|
||||
}
|
||||
|
||||
$messages = $query->orderByDesc('id')->limit($limit + 1)->get();
|
||||
|
||||
$hasMore = $messages->count() > $limit;
|
||||
$messages = $messages->take($limit)->values();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->payloads->messages($messages, $pid),
|
||||
'meta' => [
|
||||
'has_more' => $hasMore,
|
||||
'newest_id' => $messages->isNotEmpty() ? (string) $messages->first()->id : null,
|
||||
'oldest_id' => $messages->isNotEmpty() ? (string) $messages->last()->id : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1.1/direct/conversations/{id}/messages
|
||||
*
|
||||
* Text, media, or both in a single message. Media is uploaded first
|
||||
* through /api/v2/media and referenced here by id.
|
||||
*/
|
||||
public function send(Request $request, $id): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
$this->validate($request, [
|
||||
'message' => 'nullable|required_without:media_ids|string|max:'.(int) config('dm.max_message_length'),
|
||||
'media_ids' => 'nullable|required_without:message|array|max:'.(int) config('dm.max_media'),
|
||||
'media_ids.*' => 'integer|min:1|distinct',
|
||||
'in_reply_to_id' => 'sometimes|nullable|integer|min:1',
|
||||
'sensitive' => 'sometimes|boolean',
|
||||
'type' => 'sometimes|string|in:text,emoji',
|
||||
]);
|
||||
|
||||
[$conversation] = $this->resolve($request, $id);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$message = $this->service->sendMessage($conversation, $user->profile, [
|
||||
'body' => $request->input('message'),
|
||||
'type' => $request->input('type'),
|
||||
'media' => $this->service->attachableMedia($user, $request->input('media_ids', []) ?? []),
|
||||
'in_reply_to_id' => $request->input('in_reply_to_id'),
|
||||
'is_sensitive' => $request->boolean('sensitive'),
|
||||
]);
|
||||
|
||||
return response()->json($this->payloads->message($message->load('media'), $user->profile_id), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1.1/direct/conversations/{id}/messages/{messageId}
|
||||
*/
|
||||
public function deleteMessage(Request $request, $id, $messageId): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
[$conversation] = $this->resolve($request, $id);
|
||||
|
||||
$message = DmMessage::where('conversation_id', $conversation->id)
|
||||
->where('profile_id', $request->user()->profile_id)
|
||||
->findOrFail($messageId);
|
||||
|
||||
$this->service->deleteMessage($message);
|
||||
|
||||
return response()->json(['deleted' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1.1/direct/conversations/{id}/read
|
||||
*/
|
||||
public function read(Request $request, $id): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
$this->validate($request, [
|
||||
'message_id' => 'sometimes|nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
[, $participant] = $this->resolve($request, $id);
|
||||
|
||||
$this->service->markRead($participant, $request->filled('message_id') ? (int) $request->input('message_id') : null);
|
||||
|
||||
return $this->state($participant);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1.1/direct/conversations/{id}/accept
|
||||
*/
|
||||
public function accept(Request $request, $id): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
[, $participant] = $this->resolve($request, $id);
|
||||
|
||||
$this->service->accept($participant);
|
||||
|
||||
return $this->state($participant);
|
||||
}
|
||||
|
||||
public function mute(Request $request, $id): JsonResponse
|
||||
{
|
||||
return $this->toggle($request, $id, fn ($participant) => $this->service->setMuted($participant, true));
|
||||
}
|
||||
|
||||
public function unmute(Request $request, $id): JsonResponse
|
||||
{
|
||||
return $this->toggle($request, $id, fn ($participant) => $this->service->setMuted($participant, false));
|
||||
}
|
||||
|
||||
public function hide(Request $request, $id): JsonResponse
|
||||
{
|
||||
return $this->toggle($request, $id, fn ($participant) => $this->service->setHidden($participant, true));
|
||||
}
|
||||
|
||||
public function unhide(Request $request, $id): JsonResponse
|
||||
{
|
||||
return $this->toggle($request, $id, fn ($participant) => $this->service->setHidden($participant, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1.1/direct/conversations/{id}/leave
|
||||
*/
|
||||
public function leave(Request $request, $id): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
[$conversation, $participant] = $this->resolve($request, $id);
|
||||
|
||||
$this->service->leave($conversation, $participant);
|
||||
|
||||
return $this->state($participant);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Internals
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
protected function toggle(Request $request, $id, callable $action): JsonResponse
|
||||
{
|
||||
$this->authorizeRequest($request, 'write');
|
||||
|
||||
[, $participant] = $this->resolve($request, $id);
|
||||
|
||||
$action($participant);
|
||||
|
||||
return $this->state($participant);
|
||||
}
|
||||
|
||||
protected function state(DmConversationParticipant $participant): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'id' => (string) $participant->conversation_id,
|
||||
'state' => $participant->state,
|
||||
'unread_count' => (int) $participant->unread_count,
|
||||
'muted' => $participant->muted_at !== null,
|
||||
'hidden' => $participant->hidden_at !== null,
|
||||
'last_read_message_id' => $participant->last_read_message_id ? (string) $participant->last_read_message_id : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth tokens need the matching scope. Session and cookie auth have no
|
||||
* token and are allowed through, like the rest of the web API.
|
||||
*/
|
||||
protected function authorizeRequest(Request $request, string $scope): void
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_if(! $user, 403);
|
||||
|
||||
if ($user->token() && ! $user->tokenCan($scope)) {
|
||||
abort(403, 'Missing required scope: '.$scope);
|
||||
}
|
||||
|
||||
abort_if(! $this->service->canUseDirectMessages($user), 403, 'Invalid permissions for this action');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: DmConversation, 1: DmConversationParticipant}
|
||||
*/
|
||||
protected function resolve(Request $request, $id): array
|
||||
{
|
||||
abort_unless(is_numeric($id), 404);
|
||||
|
||||
$found = $this->service->conversationFor($id, $request->user()->profile_id);
|
||||
|
||||
abort_if(! $found, 404);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer's conversations for one tab of the inbox. A conversation
|
||||
* shows up once something in it is visible to the viewer.
|
||||
*/
|
||||
protected function inboxQuery(int $profileId, string $filter)
|
||||
{
|
||||
$query = DmConversationParticipant::where('profile_id', $profileId)
|
||||
->whereNotNull('last_activity_at');
|
||||
|
||||
match ($filter) {
|
||||
'requests' => $query->where('state', DmConversationParticipant::STATE_REQUEST)->whereNull('hidden_at'),
|
||||
'hidden' => $query->whereIn('state', [DmConversationParticipant::STATE_ACTIVE, DmConversationParticipant::STATE_REQUEST])->whereNotNull('hidden_at'),
|
||||
default => $query->where('state', DmConversationParticipant::STATE_ACTIVE)->whereNull('hidden_at'),
|
||||
};
|
||||
|
||||
return $query->orderByDesc('last_activity_at')->orderByDesc('id');
|
||||
}
|
||||
|
||||
protected function lastVisibleMessage(DmConversation $conversation, int $viewerId): ?DmMessage
|
||||
{
|
||||
if (! $conversation->last_message_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = DmMessage::with('media')->find($conversation->last_message_id);
|
||||
|
||||
if ($message && in_array((int) $message->profile_id, $this->payloads->blockedIds($viewerId), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Federation;
|
||||
|
||||
use App\Exceptions\InvalidDeliveryDestinationException;
|
||||
use App\Models\Profile;
|
||||
use App\Services\ActivityPubDeliveryService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Delivers one direct message activity (Create or Delete) to one inbox.
|
||||
*
|
||||
* A message that is lost to a timeout or a 503 is simply never seen by the
|
||||
* person it was for, so a temporary failure is retried instead of dropped.
|
||||
*/
|
||||
class DeliverDirectMessageActivity implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Seconds to wait before each retry.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public const RETRY_DELAYS = [30, 120, 600, 3600];
|
||||
|
||||
/**
|
||||
* Statuses below 500 that are still worth another attempt.
|
||||
*/
|
||||
private const array RETRYABLE_STATUSES = [408, 425, 429];
|
||||
|
||||
public $timeout = 60;
|
||||
|
||||
// One attempt plus the retries above
|
||||
public $tries = 5;
|
||||
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $activity
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $fromProfileId,
|
||||
protected string $inbox,
|
||||
protected array $activity
|
||||
) {}
|
||||
|
||||
public function inbox(): string
|
||||
{
|
||||
return $this->inbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function activity(): array
|
||||
{
|
||||
return $this->activity;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$from = Profile::find($this->fromProfileId);
|
||||
|
||||
if (! $from || $from->domain !== null || $from->status !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = ActivityPubDeliveryService::queue()
|
||||
->from($from)
|
||||
->to($this->inbox)
|
||||
->payload($this->activity)
|
||||
->deliver();
|
||||
} catch (InvalidDeliveryDestinationException|InvalidArgumentException) {
|
||||
// Banned host, bad inbox URL, sender without keys: retrying cannot help
|
||||
return;
|
||||
}
|
||||
|
||||
// Null means nothing reached the remote (connection failure, or the
|
||||
// host is currently marked unavailable), which is worth retrying.
|
||||
if ($response && ! self::shouldRetry($response->status())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attempt = $this->attempts();
|
||||
|
||||
if ($attempt > count(self::RETRY_DELAYS)) {
|
||||
Log::warning('DeliverDirectMessageActivity: giving up', [
|
||||
'profile_id' => $from->id,
|
||||
'inbox' => $this->inbox,
|
||||
'type' => $this->activity['type'] ?? null,
|
||||
'id' => $this->activity['id'] ?? null,
|
||||
'status' => $response?->status(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->release(self::RETRY_DELAYS[$attempt - 1]);
|
||||
}
|
||||
|
||||
public static function shouldRetry(int $status): bool
|
||||
{
|
||||
return $status >= 500 || in_array($status, self::RETRYABLE_STATUSES, true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\HasSnowflakePrimary;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $type
|
||||
* @property string $participants_hash
|
||||
* @property string|null $name
|
||||
* @property string|null $context_uri
|
||||
* @property string|null $conversation_uri
|
||||
* @property int|null $created_by_profile_id
|
||||
* @property int|null $last_message_id
|
||||
* @property Carbon|null $last_message_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
class DmConversation extends Model
|
||||
{
|
||||
use HasSnowflakePrimary;
|
||||
|
||||
public const TYPE_DM = 'dm';
|
||||
|
||||
public const TYPE_GROUP = 'group';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_message_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation is its set of participants. The same people always land
|
||||
* in the same conversation, whichever server started the thread.
|
||||
*
|
||||
* @param array<int, int|string> $profileIds
|
||||
*/
|
||||
public static function participantsHash(array $profileIds): string
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $profileIds)));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
|
||||
return hash('sha256', implode(':', $ids));
|
||||
}
|
||||
|
||||
public static function dmHash(int $a, int $b): string
|
||||
{
|
||||
return self::participantsHash([$a, $b]);
|
||||
}
|
||||
|
||||
public function isGroup(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_GROUP;
|
||||
}
|
||||
|
||||
public function participants(): HasMany
|
||||
{
|
||||
return $this->hasMany(DmConversationParticipant::class, 'conversation_id');
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(DmMessage::class, 'conversation_id');
|
||||
}
|
||||
|
||||
public function lastMessage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DmMessage::class, 'last_message_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The ActivityPub `context` replies are sent with.
|
||||
*/
|
||||
public function contextUri(): string
|
||||
{
|
||||
return $this->context_uri ?: self::localContextUri($this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The OStatus `conversation` replies are sent with. Mastodon groups
|
||||
* statuses by this value when it cannot resolve inReplyTo.
|
||||
*/
|
||||
public function conversationUri(): string
|
||||
{
|
||||
return $this->conversation_uri ?: $this->contextUri();
|
||||
}
|
||||
|
||||
public static function localContextUri(int|string $id): string
|
||||
{
|
||||
return url('/i/dm/contexts/'.$id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $conversation_id
|
||||
* @property int $profile_id
|
||||
* @property string $state
|
||||
* @property int|null $last_read_message_id
|
||||
* @property int $unread_count
|
||||
* @property Carbon|null $last_activity_at
|
||||
* @property Carbon|null $muted_at
|
||||
* @property Carbon|null $hidden_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
class DmConversationParticipant extends Model
|
||||
{
|
||||
public const STATE_ACTIVE = 'active';
|
||||
|
||||
public const STATE_REQUEST = 'request';
|
||||
|
||||
public const STATE_LEFT = 'left';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'unread_count' => 'integer',
|
||||
'last_activity_at' => 'datetime',
|
||||
'muted_at' => 'datetime',
|
||||
'hidden_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DmConversation::class, 'conversation_id');
|
||||
}
|
||||
|
||||
public function profile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Profile::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->state === self::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
public function isRequest(): bool
|
||||
{
|
||||
return $this->state === self::STATE_REQUEST;
|
||||
}
|
||||
|
||||
public function hasLeft(): bool
|
||||
{
|
||||
return $this->state === self::STATE_LEFT;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\HasSnowflakePrimary;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $conversation_id
|
||||
* @property int $profile_id
|
||||
* @property string $type
|
||||
* @property string|null $body
|
||||
* @property array|null $entities
|
||||
* @property array|null $meta
|
||||
* @property bool $is_sensitive
|
||||
* @property string|null $ap_object_uri
|
||||
* @property string|null $ap_object_hash
|
||||
* @property int|null $in_reply_to_id
|
||||
* @property int|null $status_id
|
||||
* @property int|null $legacy_dm_id
|
||||
* @property Carbon|null $edited_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
*/
|
||||
class DmMessage extends Model
|
||||
{
|
||||
use HasSnowflakePrimary, SoftDeletes;
|
||||
|
||||
public const TYPE_TEXT = 'text';
|
||||
|
||||
public const TYPE_EMOJI = 'emoji';
|
||||
|
||||
public const TYPE_LINK = 'link';
|
||||
|
||||
public const TYPE_PHOTO = 'photo';
|
||||
|
||||
public const TYPE_PHOTOS = 'photos';
|
||||
|
||||
public const TYPE_VIDEO = 'video';
|
||||
|
||||
public const TYPE_VIDEOS = 'videos';
|
||||
|
||||
public const TYPE_MEDIA = 'media';
|
||||
|
||||
public const TYPE_STORY_REACT = 'story:react';
|
||||
|
||||
public const TYPE_STORY_COMMENT = 'story:comment';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'entities' => 'array',
|
||||
'meta' => 'array',
|
||||
'is_sensitive' => 'boolean',
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (DmMessage $message) {
|
||||
if ($message->isDirty('ap_object_uri')) {
|
||||
$message->ap_object_hash = $message->ap_object_uri
|
||||
? self::hashUri($message->ap_object_uri)
|
||||
: null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Object ids are looked up by hash so the unique index never depends on
|
||||
* how long a remote server makes its ids.
|
||||
*/
|
||||
public static function hashUri(string $uri): string
|
||||
{
|
||||
return hash('sha256', $uri);
|
||||
}
|
||||
|
||||
protected function scopeWhereObjectUri(Builder $query, string $uri): Builder
|
||||
{
|
||||
return $query->where('ap_object_hash', self::hashUri($uri));
|
||||
}
|
||||
|
||||
public static function localObjectUri(int|string $id): string
|
||||
{
|
||||
return url('/i/dm/messages/'.$id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The id this message is known by on the network.
|
||||
*/
|
||||
public function objectUri(): string
|
||||
{
|
||||
return $this->ap_object_uri ?: self::localObjectUri($this->id);
|
||||
}
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DmConversation::class, 'conversation_id');
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Profile::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'in_reply_to_id');
|
||||
}
|
||||
|
||||
public function media(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Media::class, 'dm_message_media', 'message_id', 'media_id')
|
||||
->withPivot('position')
|
||||
->orderBy('dm_message_media.position');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Transformer\Api\MediaTransformer;
|
||||
use App\Util\Lexer\Autolink;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DirectMessagePayloadService
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Conversations
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param Collection<int, DmConversationParticipant> $rows The viewer's participant rows, with `conversation` loaded
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function conversations(Collection $rows, int $viewerId): array
|
||||
{
|
||||
if ($rows->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$conversationIds = $rows->pluck('conversation_id')->all();
|
||||
|
||||
$members = DmConversationParticipant::whereIn('conversation_id', $conversationIds)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->groupBy('conversation_id');
|
||||
|
||||
$lastMessages = DmMessage::with('media')
|
||||
->whereIn('id', $rows->map(fn ($row) => $row->conversation?->last_message_id)->filter()->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$blocked = $this->blockedIds($viewerId);
|
||||
|
||||
return $rows
|
||||
->filter(fn ($row) => $row->conversation !== null)
|
||||
->map(function (DmConversationParticipant $row) use ($members, $lastMessages, $viewerId, $blocked) {
|
||||
$last = $lastMessages->get($row->conversation->last_message_id);
|
||||
|
||||
if ($last && in_array((int) $last->profile_id, $blocked, true)) {
|
||||
$last = null;
|
||||
}
|
||||
|
||||
return $this->conversation(
|
||||
$row->conversation,
|
||||
$row,
|
||||
$members->get($row->conversation_id, collect()),
|
||||
$last,
|
||||
$viewerId
|
||||
);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, DmConversationParticipant>|null $members
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function conversation(DmConversation $conversation, DmConversationParticipant $viewer, ?Collection $members, ?DmMessage $last, int $viewerId): array
|
||||
{
|
||||
$members ??= DmConversationParticipant::where('conversation_id', $conversation->id)->orderBy('id')->get();
|
||||
|
||||
$participants = $members
|
||||
->filter(fn ($member) => (int) $member->profile_id !== $viewerId)
|
||||
->map(fn ($member) => AccountService::get($member->profile_id, true))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => (string) $conversation->id,
|
||||
'type' => $conversation->type,
|
||||
'name' => $conversation->name,
|
||||
'participants' => $participants,
|
||||
'participant_count' => $members->count(),
|
||||
'state' => $viewer->state,
|
||||
'unread_count' => (int) $viewer->unread_count,
|
||||
'muted' => $viewer->muted_at !== null,
|
||||
'hidden' => $viewer->hidden_at !== null,
|
||||
'last_read_message_id' => $viewer->last_read_message_id ? (string) $viewer->last_read_message_id : null,
|
||||
'last_message' => $last ? $this->message($last, $viewerId) : null,
|
||||
'created_at' => $this->timestamp($conversation->created_at),
|
||||
'updated_at' => $this->timestamp($viewer->last_activity_at ?? $conversation->last_message_at ?? $conversation->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Messages
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Text and media always travel together. `type` is only a hint for which
|
||||
* bubble to draw.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function message(DmMessage $message, int $viewerId): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $message->id,
|
||||
'conversation_id' => (string) $message->conversation_id,
|
||||
'sender_id' => (string) $message->profile_id,
|
||||
'is_author' => (int) $message->profile_id === $viewerId,
|
||||
'type' => $message->type,
|
||||
'text' => $message->body,
|
||||
'media' => $this->media($message),
|
||||
'meta' => $message->meta,
|
||||
'sensitive' => (bool) $message->is_sensitive,
|
||||
'in_reply_to_id' => $message->in_reply_to_id ? (string) $message->in_reply_to_id : null,
|
||||
'edited_at' => $this->timestamp($message->edited_at),
|
||||
'created_at' => $this->timestamp($message->created_at),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, DmMessage> $messages
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function messages(Collection $messages, int $viewerId): array
|
||||
{
|
||||
return $messages->map(fn (DmMessage $message) => $this->message($message, $viewerId))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function media(DmMessage $message): array
|
||||
{
|
||||
$media = $message->relationLoaded('media') ? $message->media : $message->media()->get();
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return FractalService::collection($media, new MediaTransformer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the `/direct/thread` endpoints have always returned.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function legacyMessage(DmMessage $message, int $viewerId, bool $hidden = false, ?int $otherLastReadId = null, ?int $viewerLastReadId = null): array
|
||||
{
|
||||
$media = $this->media($message);
|
||||
$isAuthor = (int) $message->profile_id === $viewerId;
|
||||
$readMarker = $isAuthor ? $otherLastReadId : $viewerLastReadId;
|
||||
|
||||
return [
|
||||
'id' => (string) $message->id,
|
||||
'hidden' => $hidden,
|
||||
'isAuthor' => $isAuthor,
|
||||
'type' => $message->type,
|
||||
'text' => $message->body,
|
||||
'media' => $media[0]['url'] ?? null,
|
||||
'carousel' => $media,
|
||||
'created_at' => $message->created_at->format('c'),
|
||||
'timeAgo' => $message->created_at->diffForHumans(null, null, true),
|
||||
'seen' => $readMarker !== null && $readMarker >= $message->id,
|
||||
'reportId' => (string) $message->id,
|
||||
'meta' => $message->meta,
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mastodon compatible
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param Collection<int, DmConversationParticipant> $members
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function mastodonConversation(DmConversation $conversation, DmConversationParticipant $viewer, Collection $members, ?DmMessage $last, int $viewerId): ?array
|
||||
{
|
||||
$accounts = $members
|
||||
->filter(fn ($member) => (int) $member->profile_id !== $viewerId)
|
||||
->map(fn ($member) => AccountService::getMastodon($member->profile_id, true))
|
||||
->filter(fn ($account) => $account && isset($account['id']))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($accounts) || ! $last) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string) $conversation->id,
|
||||
'unread' => $viewer->unread_count > 0,
|
||||
'accounts' => $accounts,
|
||||
'last_status' => $this->mastodonStatus($last, $accounts, $viewerId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages are not statuses any more, but Mastodon clients expect one as
|
||||
* `last_status`, so this builds the entity from the message.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $accounts
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function mastodonStatus(DmMessage $message, array $accounts, int $viewerId): array
|
||||
{
|
||||
$media = collect($this->media($message))->map(function (array $item) {
|
||||
$mime = $item['mime'] ?? null;
|
||||
|
||||
unset(
|
||||
$item['optimized_url'],
|
||||
$item['license'],
|
||||
$item['is_nsfw'],
|
||||
$item['orientation'],
|
||||
$item['filter_name'],
|
||||
$item['filter_class'],
|
||||
$item['mime'],
|
||||
$item['hls_manifest']
|
||||
);
|
||||
|
||||
$item['type'] = $mime ? strtolower(explode('/', $mime)[0]) : 'unknown';
|
||||
|
||||
return $item;
|
||||
})->values()->all();
|
||||
|
||||
$uri = $message->objectUri();
|
||||
|
||||
return [
|
||||
'id' => (string) $message->id,
|
||||
'uri' => $uri,
|
||||
'url' => $uri,
|
||||
'in_reply_to_id' => $message->in_reply_to_id ? (string) $message->in_reply_to_id : null,
|
||||
'in_reply_to_account_id' => null,
|
||||
'reblog' => null,
|
||||
'content' => self::renderHtml($message->body),
|
||||
'content_text' => $message->body,
|
||||
'created_at' => $this->timestamp($message->created_at),
|
||||
'edited_at' => $this->timestamp($message->edited_at),
|
||||
'emojis' => [],
|
||||
'replies_count' => 0,
|
||||
'reblogs_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'reblogged' => false,
|
||||
'favourited' => false,
|
||||
'muted' => false,
|
||||
'bookmarked' => false,
|
||||
'sensitive' => (bool) $message->is_sensitive,
|
||||
'spoiler_text' => '',
|
||||
'visibility' => 'direct',
|
||||
'application' => null,
|
||||
'language' => null,
|
||||
'mentions' => collect($accounts)
|
||||
->filter(fn ($account) => (string) $account['id'] !== (string) $message->profile_id)
|
||||
->map(fn ($account) => [
|
||||
'id' => (string) $account['id'],
|
||||
'username' => $account['username'] ?? null,
|
||||
'acct' => $account['acct'] ?? null,
|
||||
'url' => $account['url'] ?? null,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'tags' => [],
|
||||
'card' => null,
|
||||
'poll' => null,
|
||||
'pf_type' => $message->type,
|
||||
'media_attachments' => $media,
|
||||
'account' => AccountService::getMastodon($message->profile_id, true),
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bodies are stored as plain text. Escape first, then link, so nothing a
|
||||
* person typed can turn into markup here or on a remote server.
|
||||
*/
|
||||
public static function renderHtml(?string $body): string
|
||||
{
|
||||
if ($body === null || $body === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$escaped = htmlspecialchars($body, ENT_NOQUOTES, 'UTF-8');
|
||||
|
||||
return '<p>'.nl2br(Autolink::create()->autolink($escaped), false).'</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function blockedIds(int $viewerId): array
|
||||
{
|
||||
return array_map('intval', UserFilterService::blocks($viewerId) ?: []);
|
||||
}
|
||||
|
||||
protected function timestamp($value): ?string
|
||||
{
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace('+00:00', 'Z', $value->format(DATE_RFC3339_EXTENDED));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,836 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\DirectMessageException;
|
||||
use App\Federation\ActivityBuilders\DirectMessageActivityBuilder;
|
||||
use App\Jobs\Federation\DeliverDirectMessageActivity;
|
||||
use App\Jobs\PushNotificationPipeline\MentionPushNotifyPipeline;
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Profile;
|
||||
use App\Models\User;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DirectMessageService
|
||||
{
|
||||
public function canUseDirectMessages(User $user): bool
|
||||
{
|
||||
if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* New accounts have to wait before they can message anyone, unless the
|
||||
* admin turned that off.
|
||||
*/
|
||||
public function canInitiateConversation(User $user): bool
|
||||
{
|
||||
if ($user->is_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((bool) config_cache('instance.allow_new_account_dms')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $user->created_at->gt(now()->subHours(72));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $sender may message $recipient at all. Blocks in either
|
||||
* direction, domain blocks, and unreachable or disabled accounts all say
|
||||
* no. Privacy settings do not: those turn the conversation into a request.
|
||||
*/
|
||||
public function canMessage(Profile $sender, Profile $recipient): bool
|
||||
{
|
||||
if ($sender->id === $recipient->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($recipient->status !== null || $sender->status !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isBlockedBy($recipient, $sender) || $this->isBlockedBy($sender, $recipient)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($recipient->domain !== null) {
|
||||
if ($sender->domain !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $recipient->inbox_url && ! $recipient->sharedInbox) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) config('federation.activitypub.enabled');
|
||||
}
|
||||
|
||||
return (bool) $recipient->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when $owner blocks $other, either the account or its whole domain.
|
||||
*/
|
||||
public function isBlockedBy(Profile $owner, Profile $other): bool
|
||||
{
|
||||
if ($owner->domain !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$blocks = UserFilterService::blocks($owner->id);
|
||||
|
||||
if ($blocks && in_array($other->id, array_map('intval', $blocks), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($other->domain && AccountService::blocksDomain($owner->id, $other->domain) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a recipient joins a conversation in. Someone who only takes
|
||||
* messages from people they follow gets a request instead.
|
||||
*/
|
||||
public function initialState(Profile $recipient, Profile $sender): string
|
||||
{
|
||||
if ($recipient->domain !== null || $recipient->id === $sender->id) {
|
||||
return DmConversationParticipant::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
$acceptsEveryone = (bool) optional(optional($recipient->user)->settings)->public_dm;
|
||||
|
||||
if ($acceptsEveryone && ! $recipient->is_private) {
|
||||
return DmConversationParticipant::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
return $recipient->follows($sender)
|
||||
? DmConversationParticipant::STATE_ACTIVE
|
||||
: DmConversationParticipant::STATE_REQUEST;
|
||||
}
|
||||
|
||||
public function findDm(int $a, int $b): ?DmConversation
|
||||
{
|
||||
return DmConversation::where('participants_hash', DmConversation::dmHash($a, $b))->first();
|
||||
}
|
||||
|
||||
public function findOrCreateDm(Profile $sender, Profile $recipient): DmConversation
|
||||
{
|
||||
return $this->findOrCreateConversation($sender, collect([$recipient]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the conversation these people share, or start it.
|
||||
*
|
||||
* @param Collection<int, Profile> $others
|
||||
* @param array{context_uri?: ?string, conversation_uri?: ?string, name?: ?string} $attributes
|
||||
*/
|
||||
public function findOrCreateConversation(Profile $creator, Collection $others, array $attributes = []): DmConversation
|
||||
{
|
||||
$profiles = collect([$creator])->concat($others)->unique('id')->values();
|
||||
|
||||
if ($profiles->count() < 2) {
|
||||
throw new DirectMessageException('A conversation needs at least two participants.', 422);
|
||||
}
|
||||
|
||||
if ($profiles->count() > 2) {
|
||||
if (! config('dm.groups.enabled')) {
|
||||
throw new DirectMessageException('Group conversations are not enabled.', 422);
|
||||
}
|
||||
|
||||
if ($profiles->count() > (int) config('dm.groups.max_participants')) {
|
||||
throw new DirectMessageException('Too many participants.', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$hash = DmConversation::participantsHash($profiles->pluck('id')->all());
|
||||
|
||||
$conversation = DmConversation::where('participants_hash', $hash)->first();
|
||||
|
||||
if ($conversation) {
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($creator, $profiles, $hash, $attributes) {
|
||||
$conversation = new DmConversation;
|
||||
$conversation->id = SnowflakeService::next();
|
||||
$conversation->type = $profiles->count() > 2 ? DmConversation::TYPE_GROUP : DmConversation::TYPE_DM;
|
||||
$conversation->participants_hash = $hash;
|
||||
$conversation->name = $attributes['name'] ?? null;
|
||||
$conversation->context_uri = ($attributes['context_uri'] ?? null)
|
||||
?: DmConversation::localContextUri($conversation->id);
|
||||
$conversation->conversation_uri = $attributes['conversation_uri'] ?? null;
|
||||
$conversation->created_by_profile_id = $creator->id;
|
||||
$conversation->save();
|
||||
|
||||
$now = now();
|
||||
|
||||
DmConversationParticipant::insert($profiles->map(fn (Profile $profile) => [
|
||||
'conversation_id' => $conversation->id,
|
||||
'profile_id' => $profile->id,
|
||||
'state' => $this->initialState($profile, $creator),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])->all());
|
||||
|
||||
return $conversation;
|
||||
});
|
||||
} catch (QueryException $e) {
|
||||
// Lost a race to create the same conversation
|
||||
$conversation = DmConversation::where('participants_hash', $hash)->first();
|
||||
|
||||
if ($conversation) {
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function participant(DmConversation|int $conversation, int $profileId): ?DmConversationParticipant
|
||||
{
|
||||
$id = $conversation instanceof DmConversation ? $conversation->id : $conversation;
|
||||
|
||||
return DmConversationParticipant::where('conversation_id', $id)
|
||||
->where('profile_id', $profileId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation, but only for someone who is in it and has not left.
|
||||
*
|
||||
* @return array{0: DmConversation, 1: DmConversationParticipant}|null
|
||||
*/
|
||||
public function conversationFor(int|string $conversationId, int $profileId): ?array
|
||||
{
|
||||
$participant = DmConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('profile_id', $profileId)
|
||||
->where('state', '!=', DmConversationParticipant::STATE_LEFT)
|
||||
->first();
|
||||
|
||||
if (! $participant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$conversation = DmConversation::find($conversationId);
|
||||
|
||||
return $conversation ? [$conversation, $participant] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile ids of everyone in the conversation.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function participantIds(DmConversation|int $conversation): array
|
||||
{
|
||||
$id = $conversation instanceof DmConversation ? $conversation->id : $conversation;
|
||||
|
||||
return DmConversationParticipant::where('conversation_id', $id)
|
||||
->orderBy('id')
|
||||
->pluck('profile_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote servers are the source of truth for the thread identifiers, so
|
||||
* the newest inbound values win and are what our replies carry.
|
||||
*/
|
||||
public function adoptContext(DmConversation $conversation, ?string $contextUri, ?string $conversationUri): void
|
||||
{
|
||||
$dirty = false;
|
||||
|
||||
if ($contextUri && strlen($contextUri) <= 1024 && $conversation->context_uri !== $contextUri) {
|
||||
$conversation->context_uri = $contextUri;
|
||||
$dirty = true;
|
||||
}
|
||||
|
||||
if ($conversationUri && strlen($conversationUri) <= 1024 && $conversation->conversation_uri !== $conversationUri) {
|
||||
$conversation->conversation_uri = $conversationUri;
|
||||
$dirty = true;
|
||||
}
|
||||
|
||||
if ($dirty) {
|
||||
$conversation->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message from a local profile.
|
||||
*
|
||||
* @param array{body?: ?string, type?: ?string, media?: ?Collection, in_reply_to_id?: ?int, is_sensitive?: bool} $data
|
||||
*/
|
||||
public function sendMessage(DmConversation $conversation, Profile $sender, array $data): DmMessage
|
||||
{
|
||||
$participant = $this->participant($conversation, $sender->id);
|
||||
|
||||
if (! $participant || $participant->hasLeft()) {
|
||||
throw new DirectMessageException('You are not part of this conversation.', 403);
|
||||
}
|
||||
|
||||
$body = isset($data['body']) ? trim((string) $data['body']) : '';
|
||||
$media = $data['media'] ?? collect();
|
||||
|
||||
if ($body === '' && $media->isEmpty()) {
|
||||
throw new DirectMessageException('A message needs text or media.', 422);
|
||||
}
|
||||
|
||||
$others = Profile::whereIn('id', array_diff($this->participantIds($conversation), [$sender->id]))->get();
|
||||
|
||||
if (! $conversation->isGroup()) {
|
||||
$recipient = $others->first();
|
||||
|
||||
if (! $recipient || ! $this->canMessage($sender, $recipient)) {
|
||||
throw new DirectMessageException('You cannot message this account.', 403);
|
||||
}
|
||||
|
||||
$this->enforceRequestLimit($conversation, $sender, $recipient);
|
||||
} elseif ($others->filter(fn (Profile $other) => $other->status === null)->isEmpty()) {
|
||||
throw new DirectMessageException('Nobody in this conversation can be reached.', 403);
|
||||
}
|
||||
|
||||
if (! empty($data['in_reply_to_id'])) {
|
||||
$parentExists = DmMessage::where('conversation_id', $conversation->id)
|
||||
->where('id', $data['in_reply_to_id'])
|
||||
->exists();
|
||||
|
||||
if (! $parentExists) {
|
||||
throw new DirectMessageException('Invalid in_reply_to_id.', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$message = $this->storeMessage($conversation, $sender, [
|
||||
'body' => $body === '' ? null : $body,
|
||||
'type' => $data['type'] ?? null,
|
||||
'media' => $media,
|
||||
'in_reply_to_id' => $data['in_reply_to_id'] ?? null,
|
||||
'is_sensitive' => (bool) ($data['is_sensitive'] ?? false),
|
||||
]);
|
||||
|
||||
$this->federateCreate($message, $conversation, $sender, $others);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Until a request is accepted the sender only gets a few messages in.
|
||||
*/
|
||||
protected function enforceRequestLimit(DmConversation $conversation, Profile $sender, Profile $recipient): void
|
||||
{
|
||||
if ($recipient->domain !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->participant($conversation, $recipient->id);
|
||||
|
||||
if (! $state || ! $state->isRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sent = DmMessage::where('conversation_id', $conversation->id)
|
||||
->where('profile_id', $sender->id)
|
||||
->count();
|
||||
|
||||
if ($sent >= (int) config('dm.requests.sender_limit')) {
|
||||
throw new DirectMessageException('You can send more messages once your request is accepted.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a message and bring the conversation up to date. Shared by local
|
||||
* sends, inbound federation, story replies and the backfill.
|
||||
*
|
||||
* @param array{
|
||||
* body?: ?string,
|
||||
* type?: ?string,
|
||||
* media?: ?Collection,
|
||||
* meta?: ?array,
|
||||
* entities?: ?array,
|
||||
* ap_object_uri?: ?string,
|
||||
* in_reply_to_id?: ?int,
|
||||
* status_id?: ?int,
|
||||
* is_sensitive?: bool,
|
||||
* notify?: bool,
|
||||
* notification_action?: string,
|
||||
* } $data
|
||||
*/
|
||||
public function storeMessage(DmConversation $conversation, Profile $sender, array $data): DmMessage
|
||||
{
|
||||
$media = $data['media'] ?? collect();
|
||||
$body = $data['body'] ?? null;
|
||||
|
||||
[$type, $meta] = $this->resolveType($data['type'] ?? null, $body, $media, $data['meta'] ?? null);
|
||||
|
||||
$message = DB::transaction(function () use ($conversation, $sender, $data, $media, $body, $type, $meta) {
|
||||
$message = new DmMessage;
|
||||
$message->id = SnowflakeService::next();
|
||||
$message->conversation_id = $conversation->id;
|
||||
$message->profile_id = $sender->id;
|
||||
$message->type = $type;
|
||||
$message->body = $body;
|
||||
$message->meta = $meta;
|
||||
$message->entities = $data['entities'] ?? null;
|
||||
$message->is_sensitive = (bool) ($data['is_sensitive'] ?? false);
|
||||
$message->in_reply_to_id = $data['in_reply_to_id'] ?? null;
|
||||
$message->status_id = $data['status_id'] ?? null;
|
||||
$message->ap_object_uri = ($data['ap_object_uri'] ?? null) ?: DmMessage::localObjectUri($message->id);
|
||||
$message->save();
|
||||
|
||||
$position = 0;
|
||||
foreach ($media as $item) {
|
||||
DB::table('dm_message_media')->insert([
|
||||
'message_id' => $message->id,
|
||||
'media_id' => $item->id,
|
||||
'position' => $position++,
|
||||
]);
|
||||
}
|
||||
|
||||
$conversation->last_message_id = $message->id;
|
||||
$conversation->last_message_at = $message->created_at;
|
||||
$conversation->save();
|
||||
|
||||
return $message;
|
||||
});
|
||||
|
||||
$this->fanOut($conversation, $message, $sender, [
|
||||
'notify' => $data['notify'] ?? true,
|
||||
'action' => $data['notification_action'] ?? 'dm',
|
||||
]);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update every participant's view of the conversation. A recipient who
|
||||
* blocks the sender is skipped without anyone being told: they get no
|
||||
* unread count, no notification, and the message is filtered out when
|
||||
* they read the conversation.
|
||||
*
|
||||
* @param array{notify: bool, action: string} $options
|
||||
*/
|
||||
protected function fanOut(DmConversation $conversation, DmMessage $message, Profile $sender, array $options): void
|
||||
{
|
||||
$now = $message->created_at ?? now();
|
||||
|
||||
$participants = DmConversationParticipant::where('conversation_id', $conversation->id)->get();
|
||||
$profiles = Profile::whereIn('id', $participants->pluck('profile_id'))->get()->keyBy('id');
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
$profile = $profiles->get($participant->profile_id);
|
||||
|
||||
if (! $profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($profile->id === $sender->id) {
|
||||
$participant->last_read_message_id = $message->id;
|
||||
$participant->last_activity_at = $now;
|
||||
$participant->unread_count = 0;
|
||||
$participant->hidden_at = null;
|
||||
|
||||
// Replying to a request accepts it
|
||||
if ($participant->isRequest()) {
|
||||
$participant->state = DmConversationParticipant::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
$participant->save();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($profile->domain !== null || $participant->hasLeft()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isBlockedBy($profile, $sender)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participant->unread_count = $participant->unread_count + 1;
|
||||
$participant->last_activity_at = $now;
|
||||
$participant->save();
|
||||
|
||||
if (
|
||||
$options['notify'] &&
|
||||
$participant->isActive() &&
|
||||
! $participant->muted_at &&
|
||||
! $participant->hidden_at
|
||||
) {
|
||||
$this->notify($profile, $sender, $message, $options['action']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function notify(Profile $recipient, Profile $sender, DmMessage $message, string $action): void
|
||||
{
|
||||
NotificationService::createNotification(
|
||||
$recipient->id,
|
||||
$sender->id,
|
||||
$action,
|
||||
$message->id,
|
||||
DmMessage::class
|
||||
);
|
||||
|
||||
if (! NotificationAppGatewayService::enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! PushNotificationService::check('mention', $recipient->id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::whereProfileId($recipient->id)->first();
|
||||
|
||||
if ($user && $user->expo_token && $user->notify_enabled) {
|
||||
MentionPushNotifyPipeline::dispatch($user->expo_token, $sender->username)->onQueue('pushnotify');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy clients pick a renderer from `type`, newer ones read `text` and
|
||||
* `media` and treat it as a hint.
|
||||
*
|
||||
* @return array{0: string, 1: ?array}
|
||||
*/
|
||||
protected function resolveType(?string $requested, ?string $body, Collection $media, ?array $meta): array
|
||||
{
|
||||
if (in_array($requested, [DmMessage::TYPE_STORY_REACT, DmMessage::TYPE_STORY_COMMENT], true)) {
|
||||
return [$requested, $meta];
|
||||
}
|
||||
|
||||
if ($media->isNotEmpty()) {
|
||||
$photos = $media->filter(fn ($m) => str_starts_with((string) $m->mime, 'image/'))->count();
|
||||
$videos = $media->filter(fn ($m) => str_starts_with((string) $m->mime, 'video/'))->count();
|
||||
|
||||
$type = match (true) {
|
||||
$photos > 0 && $videos > 0 => DmMessage::TYPE_MEDIA,
|
||||
$videos > 1 => DmMessage::TYPE_VIDEOS,
|
||||
$videos === 1 => DmMessage::TYPE_VIDEO,
|
||||
$photos > 1 => DmMessage::TYPE_PHOTOS,
|
||||
default => DmMessage::TYPE_PHOTO,
|
||||
};
|
||||
|
||||
return [$type, $meta];
|
||||
}
|
||||
|
||||
if ($body && filter_var($body, FILTER_VALIDATE_URL) && Helpers::validateUrl($body)) {
|
||||
$host = parse_url($body, PHP_URL_HOST);
|
||||
|
||||
return [DmMessage::TYPE_LINK, array_merge($meta ?? [], [
|
||||
'domain' => $host,
|
||||
'local' => $host === parse_url(config('app.url'), PHP_URL_HOST),
|
||||
])];
|
||||
}
|
||||
|
||||
if ($requested === DmMessage::TYPE_EMOJI) {
|
||||
return [DmMessage::TYPE_EMOJI, $meta];
|
||||
}
|
||||
|
||||
return [DmMessage::TYPE_TEXT, $meta];
|
||||
}
|
||||
|
||||
/**
|
||||
* Story reactions and replies show up in the conversation between the
|
||||
* viewer and the story author. Their federation is handled by the story
|
||||
* pipeline, which uses $statusId as the ActivityPub object.
|
||||
*/
|
||||
public function storeStoryMessage(Profile $sender, Profile $storyAuthor, string $type, ?string $text, array $meta, ?int $statusId = null, ?string $objectUri = null): DmMessage
|
||||
{
|
||||
$conversation = $this->findOrCreateDm($sender, $storyAuthor);
|
||||
|
||||
// A story reply is only possible between people who already follow
|
||||
// each other's stories, so it never sits in requests
|
||||
DmConversationParticipant::where('conversation_id', $conversation->id)
|
||||
->where('state', DmConversationParticipant::STATE_REQUEST)
|
||||
->update(['state' => DmConversationParticipant::STATE_ACTIVE]);
|
||||
|
||||
return $this->storeMessage($conversation, $sender, [
|
||||
'type' => $type,
|
||||
'body' => $text,
|
||||
'meta' => $meta,
|
||||
'status_id' => $statusId,
|
||||
'ap_object_uri' => $objectUri,
|
||||
'notify' => $storyAuthor->domain === null,
|
||||
'notification_action' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploaded media the sender may attach: their own, not on a post, not
|
||||
* already in another message.
|
||||
*
|
||||
* @param array<int, int|string> $ids
|
||||
* @return Collection<int, Media>
|
||||
*/
|
||||
public function attachableMedia(User $user, array $ids): Collection
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
if (count($ids) > (int) config('dm.max_media')) {
|
||||
throw new DirectMessageException('Too many attachments.', 422);
|
||||
}
|
||||
|
||||
$media = Media::whereIn('id', $ids)
|
||||
->where('user_id', $user->id)
|
||||
->where('profile_id', $user->profile_id)
|
||||
->whereNull('status_id')
|
||||
->notInDirectMessage()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($media->count() !== count($ids)) {
|
||||
throw new DirectMessageException('Invalid media_ids.', 422);
|
||||
}
|
||||
|
||||
return collect($ids)->map(fn ($id) => $media->get($id))->values();
|
||||
}
|
||||
|
||||
public static function isMessageMedia(int|string $mediaId): bool
|
||||
{
|
||||
return DB::table('dm_message_media')->where('media_id', $mediaId)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message for everyone. Local authors also tell the other
|
||||
* servers in the conversation.
|
||||
*/
|
||||
public function deleteMessage(DmMessage $message, bool $federate = true): void
|
||||
{
|
||||
$conversation = DmConversation::find($message->conversation_id);
|
||||
$sender = Profile::find($message->profile_id);
|
||||
|
||||
// Story reactions and replies are federated by the story pipeline
|
||||
$isStory = in_array($message->type, [DmMessage::TYPE_STORY_REACT, DmMessage::TYPE_STORY_COMMENT], true);
|
||||
|
||||
if ($federate && ! $isStory && $conversation && $sender && $sender->domain === null) {
|
||||
$this->federateDelete($message, $conversation, $sender);
|
||||
}
|
||||
|
||||
$mediaIds = DB::table('dm_message_media')->where('message_id', $message->id)->pluck('media_id');
|
||||
|
||||
if ($mediaIds->isNotEmpty()) {
|
||||
DB::table('dm_message_media')->where('message_id', $message->id)->delete();
|
||||
|
||||
// Backfilled legacy media still belongs to its direct status and
|
||||
// goes away with it, everything else is ours to remove
|
||||
Media::whereIn('id', $mediaIds)->whereNull('status_id')->get()
|
||||
->each(fn (Media $media) => MediaStorageService::delete($media, true));
|
||||
}
|
||||
|
||||
Notification::where('item_type', DmMessage::class)
|
||||
->where('item_id', $message->id)
|
||||
->get()
|
||||
->each(function (Notification $notification) {
|
||||
NotificationService::del($notification->profile_id, $notification->id);
|
||||
$notification->forceDelete();
|
||||
});
|
||||
|
||||
$message->delete();
|
||||
|
||||
if ($conversation) {
|
||||
$this->refreshAfterDelete($conversation, $message);
|
||||
}
|
||||
}
|
||||
|
||||
protected function refreshAfterDelete(DmConversation $conversation, DmMessage $deleted): void
|
||||
{
|
||||
if ((int) $conversation->last_message_id === (int) $deleted->id) {
|
||||
$latest = DmMessage::where('conversation_id', $conversation->id)->orderByDesc('id')->first();
|
||||
|
||||
$conversation->last_message_id = $latest?->id;
|
||||
$conversation->last_message_at = $latest?->created_at;
|
||||
$conversation->save();
|
||||
}
|
||||
|
||||
DmConversationParticipant::where('conversation_id', $conversation->id)
|
||||
->where('profile_id', '!=', $deleted->profile_id)
|
||||
->where('unread_count', '>', 0)
|
||||
->where(function ($query) use ($deleted) {
|
||||
$query->whereNull('last_read_message_id')
|
||||
->orWhere('last_read_message_id', '<', $deleted->id);
|
||||
})
|
||||
->decrement('unread_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Story reactions and legacy rows hang off a direct status. When that
|
||||
* status is deleted the message goes with it.
|
||||
*/
|
||||
public function deleteByStatusId(int|string $statusId): void
|
||||
{
|
||||
DmMessage::where('status_id', $statusId)->get()
|
||||
->each(fn (DmMessage $message) => $this->deleteMessage($message, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Account deletion: drop what the profile wrote and take it out of its
|
||||
* conversations. Conversations nobody else is left in are removed.
|
||||
*/
|
||||
public function purgeProfile(int $profileId): void
|
||||
{
|
||||
DmMessage::where('profile_id', $profileId)
|
||||
->chunkById(200, function ($messages) {
|
||||
foreach ($messages as $message) {
|
||||
$this->deleteMessage($message, false);
|
||||
}
|
||||
});
|
||||
|
||||
$conversationIds = DmConversationParticipant::where('profile_id', $profileId)->pluck('conversation_id');
|
||||
|
||||
foreach ($conversationIds->chunk(200) as $chunk) {
|
||||
$direct = DmConversation::whereIn('id', $chunk)
|
||||
->where('type', DmConversation::TYPE_DM)
|
||||
->pluck('id');
|
||||
|
||||
if ($direct->isNotEmpty()) {
|
||||
DmMessage::whereIn('conversation_id', $direct)
|
||||
->chunkById(200, function ($messages) {
|
||||
foreach ($messages as $message) {
|
||||
$this->deleteMessage($message, false);
|
||||
}
|
||||
});
|
||||
|
||||
DmConversationParticipant::whereIn('conversation_id', $direct)->delete();
|
||||
DmConversation::whereIn('id', $direct)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
DmConversationParticipant::where('profile_id', $profileId)
|
||||
->update(['state' => DmConversationParticipant::STATE_LEFT, 'unread_count' => 0]);
|
||||
}
|
||||
|
||||
public function markRead(DmConversationParticipant $participant, ?int $upToMessageId = null): void
|
||||
{
|
||||
$query = DmMessage::where('conversation_id', $participant->conversation_id);
|
||||
|
||||
$latestId = $upToMessageId
|
||||
? (clone $query)->where('id', '<=', $upToMessageId)->max('id')
|
||||
: (clone $query)->max('id');
|
||||
|
||||
if (! $latestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($participant->last_read_message_id && $participant->last_read_message_id >= $latestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$participant->last_read_message_id = $latestId;
|
||||
$participant->unread_count = (clone $query)
|
||||
->where('id', '>', $latestId)
|
||||
->where('profile_id', '!=', $participant->profile_id)
|
||||
->count();
|
||||
$participant->save();
|
||||
}
|
||||
|
||||
public function accept(DmConversationParticipant $participant): void
|
||||
{
|
||||
if ($participant->isRequest()) {
|
||||
$participant->state = DmConversationParticipant::STATE_ACTIVE;
|
||||
$participant->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function setMuted(DmConversationParticipant $participant, bool $muted): void
|
||||
{
|
||||
$participant->muted_at = $muted ? ($participant->muted_at ?? now()) : null;
|
||||
$participant->save();
|
||||
}
|
||||
|
||||
public function setHidden(DmConversationParticipant $participant, bool $hidden): void
|
||||
{
|
||||
$participant->hidden_at = $hidden ? ($participant->hidden_at ?? now()) : null;
|
||||
$participant->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaving only changes what this person sees. The participant set is the
|
||||
* conversation's identity on the network, so it never shrinks.
|
||||
*/
|
||||
public function leave(DmConversation $conversation, DmConversationParticipant $participant): void
|
||||
{
|
||||
if (! $conversation->isGroup()) {
|
||||
throw new DirectMessageException('Only group conversations can be left.', 422);
|
||||
}
|
||||
|
||||
$participant->state = DmConversationParticipant::STATE_LEFT;
|
||||
$participant->unread_count = 0;
|
||||
$participant->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Profile> $others
|
||||
*/
|
||||
protected function federateCreate(DmMessage $message, DmConversation $conversation, Profile $sender, Collection $others): void
|
||||
{
|
||||
$inboxes = $this->remoteInboxes($others);
|
||||
|
||||
if (empty($inboxes) || ! config('federation.activitypub.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity = app(DirectMessageActivityBuilder::class)->buildCreate($message, $conversation, $sender, $others);
|
||||
|
||||
foreach ($inboxes as $inbox) {
|
||||
DeliverDirectMessageActivity::dispatch($sender->id, $inbox, $activity)->onQueue('high');
|
||||
}
|
||||
}
|
||||
|
||||
protected function federateDelete(DmMessage $message, DmConversation $conversation, Profile $sender): void
|
||||
{
|
||||
if (! config('federation.activitypub.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$others = Profile::whereIn('id', array_diff($this->participantIds($conversation), [$sender->id]))->get();
|
||||
$inboxes = $this->remoteInboxes($others);
|
||||
|
||||
if (empty($inboxes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity = app(DirectMessageActivityBuilder::class)->buildDelete($message, $sender, $others);
|
||||
|
||||
foreach ($inboxes as $inbox) {
|
||||
DeliverDirectMessageActivity::dispatch($sender->id, $inbox, $activity)->onQueue('high');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One delivery per server: people behind the same shared inbox get a
|
||||
* single copy.
|
||||
*
|
||||
* @param Collection<int, Profile> $profiles
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function remoteInboxes(Collection $profiles): array
|
||||
{
|
||||
return $profiles
|
||||
->filter(fn (Profile $profile) => $profile->domain !== null && $profile->status === null)
|
||||
->map(fn (Profile $profile) => $profile->sharedInbox ?: $profile->inbox_url)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Group conversations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A conversation is identified by its set of participants. The limit counts
|
||||
| everyone in the conversation, including the person who started it.
|
||||
| Inbound messages addressed to more actors than this are dropped.
|
||||
|
|
||||
*/
|
||||
|
||||
'groups' => [
|
||||
'enabled' => (bool) env('DM_GROUPS_ENABLED', true),
|
||||
'max_participants' => (int) env('DM_MAX_PARTICIPANTS', 10),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Message limits
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'max_message_length' => (int) env('DM_MAX_MESSAGE_LENGTH', 2000),
|
||||
|
||||
'max_media' => (int) env('DM_MAX_MEDIA', 4),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Message requests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A recipient who does not accept messages from everyone gets the
|
||||
| conversation as a request. Until they accept (or reply), a local sender
|
||||
| can only send `sender_limit` messages, and at most `inbound_limit`
|
||||
| messages from a remote sender are stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'requests' => [
|
||||
'sender_limit' => (int) env('DM_REQUEST_SENDER_LIMIT', 1),
|
||||
'inbound_limit' => (int) env('DM_REQUEST_INBOUND_LIMIT', 5),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Federation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `max_actor_fetches` caps how many unknown actors a single inbound message
|
||||
| can make this server fetch while resolving its participants.
|
||||
|
|
||||
*/
|
||||
|
||||
'federation' => [
|
||||
'max_actor_fetches' => (int) env('DM_MAX_ACTOR_FETCHES', 5),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Backfill
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The migration converts legacy direct messages inline only when there are
|
||||
| fewer rows than this. Larger instances run
|
||||
| `php artisan dm:backfill-conversations` themselves.
|
||||
|
|
||||
*/
|
||||
|
||||
'backfill' => [
|
||||
'inline_threshold' => (int) env('DM_BACKFILL_INLINE_THRESHOLD', 25000),
|
||||
],
|
||||
];
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dm_conversations', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('id')->primary();
|
||||
$table->string('type', 16)->default('dm')->index();
|
||||
$table->char('participants_hash', 64)->unique();
|
||||
$table->string('name', 100)->nullable();
|
||||
$table->string('context_uri', 1024)->nullable();
|
||||
$table->string('conversation_uri', 1024)->nullable();
|
||||
$table->unsignedBigInteger('created_by_profile_id')->nullable()->index();
|
||||
$table->unsignedBigInteger('last_message_id')->nullable();
|
||||
$table->timestamp('last_message_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dm_conversations');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dm_conversation_participants', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('conversation_id');
|
||||
$table->unsignedBigInteger('profile_id');
|
||||
$table->string('state', 16)->default('active');
|
||||
$table->unsignedBigInteger('last_read_message_id')->nullable();
|
||||
$table->unsignedInteger('unread_count')->default(0);
|
||||
$table->timestamp('last_activity_at')->nullable();
|
||||
$table->timestamp('muted_at')->nullable();
|
||||
$table->timestamp('hidden_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['conversation_id', 'profile_id'], 'dm_cp_conversation_profile_unique');
|
||||
$table->index(['profile_id', 'state', 'last_activity_at'], 'dm_cp_inbox_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dm_conversation_participants');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dm_messages', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('id')->primary();
|
||||
$table->unsignedBigInteger('conversation_id');
|
||||
$table->unsignedBigInteger('profile_id')->index();
|
||||
$table->string('type', 32)->default('text');
|
||||
$table->text('body')->nullable();
|
||||
$table->json('entities')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->boolean('is_sensitive')->default(false);
|
||||
$table->string('ap_object_uri', 1024)->nullable();
|
||||
$table->char('ap_object_hash', 64)->nullable()->unique();
|
||||
$table->unsignedBigInteger('in_reply_to_id')->nullable();
|
||||
$table->unsignedBigInteger('status_id')->nullable()->index();
|
||||
$table->unsignedBigInteger('legacy_dm_id')->nullable()->unique();
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['conversation_id', 'id'], 'dm_messages_conversation_id_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dm_messages');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dm_message_media', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('message_id')->index();
|
||||
$table->unsignedBigInteger('media_id')->unique();
|
||||
$table->unsignedTinyInteger('position')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dm_message_media');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Small instances get their legacy direct messages converted right here.
|
||||
* Anything bigger is left to `php artisan dm:backfill-conversations`,
|
||||
* which is chunked, resumable and safe to run while the app is live.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('direct_messages')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$threshold = (int) config('dm.backfill.inline_threshold', 25000);
|
||||
$count = DB::table('direct_messages')->count();
|
||||
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($count > $threshold) {
|
||||
echo PHP_EOL." {$count} legacy direct messages found, skipping the inline backfill.".PHP_EOL;
|
||||
echo ' Run: php artisan dm:backfill-conversations'.PHP_EOL.PHP_EOL;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Artisan::call('dm:backfill-conversations', ['--force' => true]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
use App\Federation\ActivityBuilders\DirectMessageActivityBuilder;
|
||||
use App\Jobs\Federation\DeliverDirectMessageActivity;
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Report;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFilter;
|
||||
use App\Services\DirectMessageService;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
require_once __DIR__.'/helpers.php';
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
|
||||
$this->withoutMiddleware(ThrottleRequests::class);
|
||||
|
||||
config([
|
||||
'instance.enable_cc' => false,
|
||||
'federation.activitypub.enabled' => true,
|
||||
'snowflake.datacenter_id' => 1,
|
||||
'snowflake.worker_id' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
function dmUpload(User $user, string $mime = 'image/jpeg'): Media
|
||||
{
|
||||
return Media::create([
|
||||
'status_id' => null,
|
||||
'profile_id' => $user->profile_id,
|
||||
'user_id' => $user->id,
|
||||
'media_path' => 'public/m/'.Str::random(12).'.jpg',
|
||||
'mime' => $mime,
|
||||
'size' => 1000,
|
||||
]);
|
||||
}
|
||||
|
||||
describe('starting a conversation', function () {
|
||||
it('creates a one to one conversation and returns the same one next time', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$first = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]]);
|
||||
$first->assertCreated()
|
||||
->assertJsonPath('type', 'dm')
|
||||
->assertJsonPath('participants.0.id', (string) $bob->profile_id)
|
||||
->assertJsonPath('last_message', null);
|
||||
|
||||
$second = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]]);
|
||||
$second->assertOk()->assertJsonPath('id', $first->json('id'));
|
||||
|
||||
expect(DmConversation::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('creates a group for several recipients', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$carol = dmRemoteProfile('carol');
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id, $carol->id]])
|
||||
->assertCreated()
|
||||
->assertJsonPath('type', 'group')
|
||||
->assertJsonPath('participant_count', 3)
|
||||
->assertJsonCount(2, 'participants');
|
||||
});
|
||||
|
||||
it('refuses a recipient who blocks the sender', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
UserFilter::create([
|
||||
'user_id' => $bob->profile_id,
|
||||
'filterable_id' => $alice->profile_id,
|
||||
'filterable_type' => 'App\Models\Profile',
|
||||
'filter_type' => 'block',
|
||||
]);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->assertForbidden();
|
||||
});
|
||||
|
||||
it('makes new accounts wait', function () {
|
||||
config(['instance.allow_new_account_dms' => false]);
|
||||
|
||||
$alice = dmLocalUser(attributes: ['created_at' => now()]);
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->assertStatus(400);
|
||||
});
|
||||
|
||||
it('requires the write scope', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read']);
|
||||
|
||||
$this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->assertForbidden();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sending', function () {
|
||||
it('sends text and media as one message', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$media = dmUpload($alice);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", [
|
||||
'message' => 'look at this',
|
||||
'media_ids' => [$media->id],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('text', 'look at this')
|
||||
->assertJsonPath('type', 'photo')
|
||||
->assertJsonPath('media.0.id', (string) $media->id)
|
||||
->assertJsonPath('is_author', true);
|
||||
|
||||
expect(DmMessage::count())->toBe(1);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}/messages")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.text', 'look at this')
|
||||
->assertJsonPath('data.0.media.0.id', (string) $media->id)
|
||||
->assertJsonPath('data.0.is_author', false);
|
||||
});
|
||||
|
||||
it('does not accept media that belongs to someone else or is already used', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$theirs = dmUpload($bob);
|
||||
$mine = dmUpload($alice);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['media_ids' => [$theirs->id]])->assertStatus(422);
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['media_ids' => [$mine->id]])->assertCreated();
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['media_ids' => [$mine->id]])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('updates the recipients inbox, unread count and notifications', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hi'])->assertCreated();
|
||||
|
||||
expect(Notification::where('profile_id', $bob->profile_id)->where('action', 'dm')->count())->toBe(1);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1.1/direct/conversations')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.unread_count', 1)
|
||||
->assertJsonPath('data.0.last_message.text', 'hi')
|
||||
->assertJsonPath('data.0.participants.0.id', (string) $alice->profile_id);
|
||||
|
||||
$this->getJson('/api/v1.1/direct/unread_count')->assertJsonPath('primary', 1);
|
||||
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/read")->assertOk()->assertJsonPath('unread_count', 0);
|
||||
});
|
||||
|
||||
it('holds a first message as a request and limits the sender until it is accepted', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser(publicDm: false);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'one'])->assertCreated();
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'two'])->assertForbidden();
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1.1/direct/conversations')->assertJsonCount(0, 'data');
|
||||
$this->getJson('/api/v1.1/direct/conversations?filter=requests')->assertJsonCount(1, 'data');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/accept")->assertJsonPath('state', 'active');
|
||||
$this->getJson('/api/v1.1/direct/conversations')->assertJsonCount(1, 'data');
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'two'])->assertCreated();
|
||||
});
|
||||
|
||||
it('accepts a request when the recipient replies', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser(publicDm: false);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'one'])->assertCreated();
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hey'])->assertCreated();
|
||||
|
||||
expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('state'))->toBe('active');
|
||||
});
|
||||
|
||||
it('hides a conversation from people who are not in it', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$eve = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'secret'])->assertCreated();
|
||||
|
||||
Passport::actingAs($eve, ['read', 'write']);
|
||||
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}")->assertNotFound();
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}/messages")->assertNotFound();
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hello?'])->assertNotFound();
|
||||
});
|
||||
|
||||
it('pages back with max_id and polls forward with min_id', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
|
||||
$ids = [];
|
||||
foreach (['a', 'b', 'c'] as $text) {
|
||||
$ids[] = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => $text])->json('id');
|
||||
}
|
||||
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}/messages?limit=2")
|
||||
->assertJsonPath('data.0.text', 'c')
|
||||
->assertJsonPath('data.1.text', 'b')
|
||||
->assertJsonPath('meta.has_more', true)
|
||||
->assertJsonPath('meta.oldest_id', $ids[1]);
|
||||
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}/messages?max_id={$ids[1]}")
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.text', 'a');
|
||||
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}/messages?min_id={$ids[0]}")
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.text', 'c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groups', function () {
|
||||
it('delivers to every member and keeps a blocked sender out of view', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$dave = dmLocalUser();
|
||||
|
||||
UserFilter::create([
|
||||
'user_id' => $dave->profile_id,
|
||||
'filterable_id' => $alice->profile_id,
|
||||
'filterable_type' => 'App\Models\Profile',
|
||||
'filter_type' => 'block',
|
||||
]);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id, $dave->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'from alice'])->assertCreated();
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'from bob'])->assertCreated();
|
||||
|
||||
Passport::actingAs($dave, ['read', 'write']);
|
||||
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}/messages")
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.text', 'from bob');
|
||||
|
||||
$this->getJson('/api/v1.1/direct/conversations')->assertJsonPath('data.0.unread_count', 1);
|
||||
});
|
||||
|
||||
it('lets a member leave without changing the conversation for the others', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$dave = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id, $dave->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hi all'])->assertCreated();
|
||||
|
||||
Passport::actingAs($dave, ['read', 'write']);
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/leave")->assertJsonPath('state', 'left');
|
||||
$this->getJson("/api/v1.1/direct/conversations/{$id}")->assertNotFound();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'still here'])->assertCreated();
|
||||
|
||||
expect(DmConversationParticipant::where('profile_id', $dave->profile_id)->value('unread_count'))->toBe(0)
|
||||
->and(DmConversationParticipant::where('conversation_id', $id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('cannot leave a one to one conversation', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/leave")->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting and reporting', function () {
|
||||
it('lets the author delete a message and repairs the conversation', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$first = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'one'])->json('id');
|
||||
$second = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'two'])->json('id');
|
||||
|
||||
$this->deleteJson("/api/v1.1/direct/conversations/{$id}/messages/{$second}")->assertOk();
|
||||
|
||||
expect((string) DmConversation::find($id)->last_message_id)->toBe($first)
|
||||
->and(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(1)
|
||||
->and(Notification::where('item_type', DmMessage::class)->where('item_id', $second)->count())->toBe(0);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->deleteJson("/api/v1.1/direct/conversations/{$id}/messages/{$first}")->assertNotFound();
|
||||
});
|
||||
|
||||
it('lets a participant report a message and nobody else', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$eve = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$message = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'spam'])->json('id');
|
||||
|
||||
Passport::actingAs($eve, ['read', 'write']);
|
||||
$this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'direct_message', 'object_id' => $message])->assertStatus(400);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'direct_message', 'object_id' => $message])->assertOk();
|
||||
|
||||
$report = Report::first();
|
||||
|
||||
expect($report->object_type)->toBe(DmMessage::class)
|
||||
->and((string) $report->object_id)->toBe($message)
|
||||
->and($report->reported_profile_id)->toBe($alice->profile_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('federation', function () {
|
||||
it('delivers once per server with the fields mastodon needs to thread it as a direct message', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmRemoteProfile('bob');
|
||||
$carol = dmRemoteProfile('carol');
|
||||
$dave = dmRemoteProfile('dave', 'other.example');
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->id, $carol->id, $dave->id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => "hello <b>all</b>\nsecond line"])->assertCreated();
|
||||
|
||||
Queue::assertPushed(DeliverDirectMessageActivity::class, 2);
|
||||
|
||||
$inboxes = [];
|
||||
$activity = null;
|
||||
|
||||
Queue::assertPushed(DeliverDirectMessageActivity::class, function ($job) use (&$inboxes, &$activity) {
|
||||
$inboxes[] = $job->inbox();
|
||||
$activity = $job->activity();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
sort($inboxes);
|
||||
expect($inboxes)->toBe(['https://other.example/inbox', 'https://remote.example/inbox']);
|
||||
|
||||
$note = $activity['object'];
|
||||
$recipients = [$bob->remote_url, $carol->remote_url, $dave->remote_url];
|
||||
|
||||
expect($activity['type'])->toBe('Create')
|
||||
->and($note['type'])->toBe('Note')
|
||||
->and($note['to'])->toEqualCanonicalizing($recipients)
|
||||
->and($note['cc'])->toBe([])
|
||||
->and(collect($note['tag'])->where('type', 'Mention')->pluck('href')->all())->toEqualCanonicalizing($recipients)
|
||||
->and($note['context'])->toBe(DmConversation::localContextUri($id))
|
||||
->and($note['conversation'])->toBe(DmConversation::localContextUri($id))
|
||||
->and($note['inReplyTo'])->toBeNull()
|
||||
->and($note['content'])->toBe("<p>hello <b>all</b><br>\nsecond line</p>");
|
||||
|
||||
expect(json_encode($activity))->not->toContain('#Public');
|
||||
});
|
||||
|
||||
it('replies into the remote thread it was started from', function () {
|
||||
$alice = dmLocalUser();
|
||||
$aliceProfile = dmProfile($alice);
|
||||
$bob = dmRemoteProfile('bob');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($bob, dmNote($bob, '1', [$aliceProfile]));
|
||||
|
||||
$conversation = DmConversation::first();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$conversation->id}/messages", ['message' => 'hi bob'])->assertCreated();
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$conversation->id}/messages", ['message' => 'and another'])->assertCreated();
|
||||
|
||||
$notes = [];
|
||||
Queue::assertPushed(DeliverDirectMessageActivity::class, function ($job) use (&$notes) {
|
||||
$notes[] = $job->activity()['object'];
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($notes[0]['inReplyTo'])->toBe($bob->remote_url.'/statuses/1')
|
||||
->and($notes[0]['context'])->toBe('https://remote.example/contexts/1')
|
||||
->and($notes[0]['conversation'])->toBe('tag:remote.example,2026-09-21:objectId=1:objectType=Conversation')
|
||||
->and($notes[1]['inReplyTo'])->toBe($notes[0]['id']);
|
||||
});
|
||||
|
||||
it('sends media and its description along with the text', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmRemoteProfile('bob');
|
||||
$media = dmUpload($alice);
|
||||
$media->update(['caption' => 'a cat', 'width' => 100, 'height' => 50]);
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
$conversation = $service->findOrCreateDm(dmProfile($alice), $bob);
|
||||
$message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'look', 'media' => collect([$media])]);
|
||||
|
||||
$note = app(DirectMessageActivityBuilder::class)->buildNote($message, $conversation, dmProfile($alice), collect([$bob]));
|
||||
|
||||
expect($note['attachment'])->toHaveCount(1)
|
||||
->and($note['attachment'][0]['mediaType'])->toBe('image/jpeg')
|
||||
->and($note['attachment'][0]['name'])->toBe('a cat')
|
||||
->and($note['content'])->toBe('<p>look</p>');
|
||||
});
|
||||
|
||||
it('tells the other servers when a message is deleted, addressed to them and not the public', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmRemoteProfile('bob');
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->id]])->json('id');
|
||||
$message = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'oops'])->json('id');
|
||||
|
||||
$this->deleteJson("/api/v1.1/direct/conversations/{$id}/messages/{$message}")->assertOk();
|
||||
|
||||
Queue::assertPushed(DeliverDirectMessageActivity::class, function ($job) use ($bob, $message) {
|
||||
$activity = $job->activity();
|
||||
|
||||
return $activity['type'] === 'Delete'
|
||||
&& $activity['to'] === [$bob->remote_url]
|
||||
&& $activity['object']['id'] === DmMessage::localObjectUri($message);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not federate a conversation between local people', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id');
|
||||
$this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'local'])->assertCreated();
|
||||
|
||||
Queue::assertNotPushed(DeliverDirectMessageActivity::class);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\Federation\DeliverDirectMessageActivity;
|
||||
use App\Models\DirectMessage;
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Status;
|
||||
use App\Models\UserFilter;
|
||||
use App\Services\DirectMessageService;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
require_once __DIR__.'/helpers.php';
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
config(['snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1]);
|
||||
});
|
||||
|
||||
function dmLegacy(Profile $from, Profile $to, string $text, array $dm = [], array $status = []): DirectMessage
|
||||
{
|
||||
$post = Status::factory()->create(array_merge([
|
||||
'profile_id' => $from->id,
|
||||
'caption' => $text,
|
||||
'scope' => 'direct',
|
||||
'visibility' => 'direct',
|
||||
'in_reply_to_profile_id' => $to->id,
|
||||
], $status));
|
||||
|
||||
$row = new DirectMessage;
|
||||
$row->from_id = $from->id;
|
||||
$row->to_id = $to->id;
|
||||
$row->status_id = $post->id;
|
||||
$row->type = $dm['type'] ?? 'text';
|
||||
$row->is_hidden = $dm['is_hidden'] ?? false;
|
||||
$row->read_at = $dm['read_at'] ?? null;
|
||||
$row->meta = $dm['meta'] ?? null;
|
||||
$row->save();
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
describe('dm:backfill-conversations', function () {
|
||||
it('turns a legacy thread into one conversation and keeps the status ids', function () {
|
||||
$alice = dmProfile(dmLocalUser());
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
|
||||
$one = dmLegacy($alice, $bob, 'first', ['read_at' => now()]);
|
||||
$two = dmLegacy($bob, $alice, 'second', ['read_at' => now()]);
|
||||
$three = dmLegacy($alice, $bob, 'third');
|
||||
|
||||
$this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful();
|
||||
|
||||
$conversation = DmConversation::first();
|
||||
|
||||
expect(DmConversation::count())->toBe(1)
|
||||
->and($conversation->participants_hash)->toBe(DmConversation::dmHash($alice->id, $bob->id))
|
||||
->and($conversation->last_message_id)->toBe($three->status_id)
|
||||
->and(DmMessage::orderBy('id')->pluck('id')->all())->toBe([$one->status_id, $two->status_id, $three->status_id])
|
||||
->and(DmMessage::orderBy('id')->pluck('body')->all())->toBe(['first', 'second', 'third'])
|
||||
->and(DmMessage::find($one->status_id)->legacy_dm_id)->toBe($one->id);
|
||||
|
||||
$bobState = DmConversationParticipant::where('profile_id', $bob->id)->first();
|
||||
$aliceState = DmConversationParticipant::where('profile_id', $alice->id)->first();
|
||||
|
||||
expect($bobState->unread_count)->toBe(1)
|
||||
->and($bobState->last_read_message_id)->toBe($one->status_id)
|
||||
->and($bobState->last_activity_at)->not->toBeNull()
|
||||
->and($aliceState->unread_count)->toBe(0)
|
||||
->and($aliceState->last_read_message_id)->toBe($three->status_id);
|
||||
});
|
||||
|
||||
it('can be run again without duplicating anything', function () {
|
||||
$alice = dmProfile(dmLocalUser());
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
|
||||
dmLegacy($alice, $bob, 'first');
|
||||
|
||||
$this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful();
|
||||
dmLegacy($bob, $alice, 'second');
|
||||
$this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful();
|
||||
$this->artisan('dm:backfill-conversations', ['--force' => true, '--full' => true])->assertSuccessful();
|
||||
|
||||
expect(DmMessage::count())->toBe(2)
|
||||
->and(DmConversation::count())->toBe(1)
|
||||
->and(DmConversationParticipant::count())->toBe(2);
|
||||
});
|
||||
|
||||
it('carries over media, filtered threads, story metadata and mutes', function () {
|
||||
$alice = dmProfile(dmLocalUser());
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$carol = dmRemoteProfile('carol');
|
||||
|
||||
$photo = dmLegacy($alice, $bob, 'with a photo', ['type' => 'photo']);
|
||||
$media = Media::create([
|
||||
'status_id' => $photo->status_id,
|
||||
'profile_id' => $alice->id,
|
||||
'media_path' => 'public/m/legacy.jpg',
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => 1000,
|
||||
]);
|
||||
|
||||
dmLegacy($carol, $bob, 'Tom & Jerry', ['is_hidden' => true], ['uri' => $carol->remote_url.'/statuses/9']);
|
||||
|
||||
dmLegacy($alice, $bob, '🔥', [
|
||||
'type' => 'story:react',
|
||||
'meta' => json_encode(['story_id' => 5, 'reaction' => '🔥']),
|
||||
], ['type' => 'story:reaction']);
|
||||
|
||||
UserFilter::create([
|
||||
'user_id' => $bob->id,
|
||||
'filterable_id' => $alice->id,
|
||||
'filterable_type' => 'App\Models\Profile',
|
||||
'filter_type' => 'dm.mute',
|
||||
]);
|
||||
|
||||
$this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful();
|
||||
|
||||
$withPhoto = DmMessage::with('media')->find($photo->status_id);
|
||||
$fromCarol = DmMessage::where('profile_id', $carol->id)->first();
|
||||
$reaction = DmMessage::where('type', 'story:react')->first();
|
||||
|
||||
expect($withPhoto->media->pluck('id')->all())->toBe([$media->id])
|
||||
->and($withPhoto->body)->toBe('with a photo')
|
||||
->and($fromCarol->body)->toBe('Tom & Jerry')
|
||||
->and($fromCarol->ap_object_uri)->toBe($carol->remote_url.'/statuses/9')
|
||||
->and(DmMessage::whereObjectUri($carol->remote_url.'/statuses/9')->exists())->toBeTrue()
|
||||
->and($reaction->meta)->toBe(['story_id' => 5, 'reaction' => '🔥']);
|
||||
|
||||
$carolThread = DmConversation::where('participants_hash', DmConversation::dmHash($carol->id, $bob->id))->first();
|
||||
$aliceThread = DmConversation::where('participants_hash', DmConversation::dmHash($alice->id, $bob->id))->first();
|
||||
|
||||
expect(DmConversationParticipant::where('conversation_id', $carolThread->id)->where('profile_id', $bob->id)->value('state'))->toBe('request')
|
||||
->and(DmConversationParticipant::where('conversation_id', $aliceThread->id)->where('profile_id', $bob->id)->value('muted_at'))->not->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', function () {
|
||||
it('removes the message when the status behind it is deleted', function () {
|
||||
$alice = dmProfile(dmLocalUser());
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
|
||||
$legacy = dmLegacy($alice, $bob, 'first');
|
||||
$this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful();
|
||||
|
||||
app(DirectMessageService::class)->deleteByStatusId($legacy->status_id);
|
||||
|
||||
expect(DmMessage::count())->toBe(0)
|
||||
->and(DmConversation::first()->last_message_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('clears a deleted profile out of its conversations', function () {
|
||||
$alice = dmProfile(dmLocalUser());
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
|
||||
$direct = $service->findOrCreateDm($alice, $bob);
|
||||
$service->sendMessage($direct, $alice, ['body' => 'one to one']);
|
||||
$service->sendMessage($direct, $bob, ['body' => 'reply']);
|
||||
|
||||
$group = $service->findOrCreateConversation($alice, collect([$bob, $dave]));
|
||||
$service->sendMessage($group, $alice, ['body' => 'from alice']);
|
||||
$service->sendMessage($group, $bob, ['body' => 'from bob']);
|
||||
|
||||
$service->purgeProfile($alice->id);
|
||||
|
||||
expect(DmConversation::find($direct->id))->toBeNull()
|
||||
->and(DmMessage::where('conversation_id', $direct->id)->count())->toBe(0)
|
||||
->and(DmMessage::where('conversation_id', $group->id)->pluck('body')->all())->toBe(['from bob'])
|
||||
->and(DmConversationParticipant::where('conversation_id', $group->id)->where('profile_id', $alice->id)->value('state'))->toBe('left');
|
||||
});
|
||||
|
||||
it('keeps direct message media away from the orphan media collector', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
$attached = Media::create(['profile_id' => $alice->profile_id, 'user_id' => $alice->id, 'media_path' => 'public/m/a.jpg', 'mime' => 'image/jpeg', 'size' => 1]);
|
||||
$orphan = Media::create(['profile_id' => $alice->profile_id, 'user_id' => $alice->id, 'media_path' => 'public/m/b.jpg', 'mime' => 'image/jpeg', 'size' => 1]);
|
||||
|
||||
DB::table('media')->whereIn('id', [$attached->id, $orphan->id])->update(['created_at' => now()->subDay()]);
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
||||
$service->sendMessage($conversation, dmProfile($alice), ['media' => collect([$attached])]);
|
||||
|
||||
$collectable = Media::whereNull('status_id')
|
||||
->notInDirectMessage()
|
||||
->where('created_at', '<', now()->subHours(2))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
expect($collectable)->toBe([$orphan->id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story replies', function () {
|
||||
it('lands in the conversation with the story author', function () {
|
||||
$alice = dmProfile(dmLocalUser());
|
||||
$bob = dmProfile(dmLocalUser(publicDm: false));
|
||||
|
||||
$message = app(DirectMessageService::class)->storeStoryMessage(
|
||||
$alice,
|
||||
$bob,
|
||||
'story:comment',
|
||||
'nice one',
|
||||
['story_id' => 1, 'caption' => 'nice one'],
|
||||
12345
|
||||
);
|
||||
|
||||
$state = DmConversationParticipant::where('profile_id', $bob->id)->first();
|
||||
|
||||
expect($message->type)->toBe('story:comment')
|
||||
->and($message->status_id)->toBe(12345)
|
||||
->and($message->meta['caption'])->toBe('nice one')
|
||||
->and($state->state)->toBe('active')
|
||||
->and($state->unread_count)->toBe(1)
|
||||
->and(Notification::where('profile_id', $bob->id)->where('action', 'story:comment')->count())->toBe(1);
|
||||
|
||||
// The story pipeline federates these, not the direct message one
|
||||
Queue::assertNotPushed(DeliverDirectMessageActivity::class);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,610 @@
|
||||
<?php
|
||||
|
||||
use App\Federation\Handlers\DirectMessageHandler;
|
||||
use App\Federation\Validators\DirectMessageValidator;
|
||||
use App\Jobs\MediaPipeline\MediaDeletePipeline;
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Status;
|
||||
use App\Models\UserDomainBlock;
|
||||
use App\Models\UserFilter;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
require_once __DIR__.'/helpers.php';
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Inbound direct messages
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Invariant: a Note addressed only to people is a direct message and never
|
||||
| anything else. It is stored in the conversation formed by its author and
|
||||
| the people it names, or it is dropped. It must never reach the code that
|
||||
| stores posts and replies, because that code files every non-public Note as
|
||||
| followers-only.
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'instance.enable_cc' => false,
|
||||
'federation.activitypub.enabled' => true,
|
||||
'snowflake.datacenter_id' => 1,
|
||||
'snowflake.worker_id' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('one to one', function () {
|
||||
it('stores a direct note as a message in a conversation with its author', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
$message = DmMessage::first();
|
||||
|
||||
expect($message)->not->toBeNull()
|
||||
->and($message->body)->toBe('hello there')
|
||||
->and($message->profile_id)->toBe($alice->id)
|
||||
->and($message->ap_object_uri)->toBe($alice->remote_url.'/statuses/1');
|
||||
|
||||
$conversation = DmConversation::find($message->conversation_id);
|
||||
|
||||
expect($conversation->type)->toBe('dm')
|
||||
->and($conversation->participants_hash)->toBe(DmConversation::dmHash($alice->id, $bob->id))
|
||||
->and($conversation->last_message_id)->toBe($message->id);
|
||||
|
||||
expect(Status::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the thread identifiers the remote server sent', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
$conversation = DmConversation::first();
|
||||
|
||||
expect($conversation->context_uri)->toBe('https://remote.example/contexts/1')
|
||||
->and($conversation->conversation_uri)->toBe('tag:remote.example,2026-09-21:objectId=1:objectType=Conversation');
|
||||
});
|
||||
|
||||
it('counts the message as unread and notifies the recipient', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
$participant = DmConversationParticipant::where('profile_id', $bob->id)->first();
|
||||
|
||||
expect($participant->state)->toBe('active')
|
||||
->and($participant->unread_count)->toBe(1)
|
||||
->and($participant->last_activity_at)->not->toBeNull();
|
||||
|
||||
$notification = Notification::where('profile_id', $bob->id)->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and($notification->action)->toBe('dm')
|
||||
->and($notification->item_type)->toBe(DmMessage::class);
|
||||
});
|
||||
|
||||
it('resolves a recipient addressed by an id based actor url', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], ['to' => [url('/users/'.$bob->id)]]));
|
||||
|
||||
expect(DmMessage::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('stores a redelivered message once', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmMessage::count())->toBe(1)
|
||||
->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1);
|
||||
});
|
||||
|
||||
it('does not store a message that already exists as a legacy direct status', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
Status::factory()->create([
|
||||
'profile_id' => $alice->id,
|
||||
'caption' => 'old',
|
||||
'scope' => 'direct',
|
||||
'visibility' => 'direct',
|
||||
'uri' => $alice->remote_url.'/statuses/1',
|
||||
]);
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmMessage::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('turns the conversation into a request when the recipient only takes messages from people they follow', function () {
|
||||
$bob = dmProfile(dmLocalUser(publicDm: false));
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmConversationParticipant::where('profile_id', $bob->id)->value('state'))->toBe('request')
|
||||
->and(Notification::where('profile_id', $bob->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('skips the request when the recipient follows the sender', function () {
|
||||
$bob = dmProfile(dmLocalUser(publicDm: false));
|
||||
$alice = dmRemoteProfile();
|
||||
dmFollow($bob, $alice);
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmConversationParticipant::where('profile_id', $bob->id)->value('state'))->toBe('active');
|
||||
});
|
||||
|
||||
it('stops storing messages once a pending request hits the inbound limit', function () {
|
||||
config(['dm.requests.inbound_limit' => 2]);
|
||||
|
||||
$bob = dmProfile(dmLocalUser(publicDm: false));
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
foreach (['1', '2', '3'] as $path) {
|
||||
dmDeliver($alice, dmNote($alice, $path, [$bob]));
|
||||
}
|
||||
|
||||
expect(DmMessage::count())->toBe(2);
|
||||
});
|
||||
|
||||
it('drops a message from someone the recipient blocks', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
UserFilter::create([
|
||||
'user_id' => $bob->id,
|
||||
'filterable_id' => $alice->id,
|
||||
'filterable_type' => 'App\Models\Profile',
|
||||
'filter_type' => 'block',
|
||||
]);
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmMessage::count())->toBe(0)
|
||||
->and(DmConversation::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('drops a message from a domain the recipient blocks', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
UserDomainBlock::create(['profile_id' => $bob->id, 'domain' => 'remote.example']);
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmMessage::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not store a message attributed to someone other than the sender', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$mallory = dmRemoteProfile('mallory', 'other.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($mallory, dmNote($alice, '1', [$bob]));
|
||||
|
||||
expect(DmMessage::count())->toBe(0)
|
||||
->and(Status::count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text and media', function () {
|
||||
it('keeps the text of a message that also has media', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], [
|
||||
'content' => '<p>look at this</p>',
|
||||
'attachment' => [[
|
||||
'type' => 'Document',
|
||||
'mediaType' => 'image/jpeg',
|
||||
'url' => 'https://remote.example/media/1.jpg',
|
||||
'name' => 'a cat',
|
||||
'blurhash' => 'UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH',
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
]],
|
||||
]));
|
||||
|
||||
$message = DmMessage::with('media')->first();
|
||||
|
||||
expect($message->body)->toBe('look at this')
|
||||
->and($message->type)->toBe('photo')
|
||||
->and($message->media)->toHaveCount(1)
|
||||
->and($message->media[0]->remote_url)->toBe('https://remote.example/media/1.jpg')
|
||||
->and($message->media[0]->status_id)->toBeNull()
|
||||
->and($message->media[0]->caption)->toBe('a cat');
|
||||
});
|
||||
|
||||
it('stores a message that is only media', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], [
|
||||
'content' => '',
|
||||
'attachment' => [
|
||||
['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg'],
|
||||
['type' => 'Document', 'mediaType' => 'image/png', 'url' => 'https://remote.example/media/2.png'],
|
||||
],
|
||||
]));
|
||||
|
||||
$message = DmMessage::with('media')->first();
|
||||
|
||||
expect($message->body)->toBeNull()
|
||||
->and($message->type)->toBe('photos')
|
||||
->and($message->media)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('ignores attachments of a type this server does not accept', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], [
|
||||
'attachment' => [
|
||||
['type' => 'Document', 'mediaType' => 'application/x-msdownload', 'url' => 'https://remote.example/media/1.exe'],
|
||||
],
|
||||
]));
|
||||
|
||||
$message = DmMessage::with('media')->first();
|
||||
|
||||
expect($message->type)->toBe('text')
|
||||
->and($message->media)->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('keeps direct message media out of the unattached media queries', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], [
|
||||
'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']],
|
||||
]));
|
||||
|
||||
expect(Media::whereNull('status_id')->count())->toBe(1)
|
||||
->and(Media::whereNull('status_id')->notInDirectMessage()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain text', function () {
|
||||
it('drops the leading mentions and keeps paragraphs and line breaks', function () {
|
||||
$html = '<p><span class="h-card"><a href="https://pixelfed.test/users/bob">@<span>bob</span></a></span> '
|
||||
.'<span class="h-card"><a href="https://other.example/users/carol">@<span>carol</span></a></span> first line<br>second line</p>'
|
||||
.'<p>new paragraph & more</p>';
|
||||
|
||||
expect(DirectMessageHandler::plainText($html))->toBe("first line\nsecond line\n\nnew paragraph & more");
|
||||
});
|
||||
|
||||
it('leaves a mention in the middle of the text alone', function () {
|
||||
expect(DirectMessageHandler::plainText('<p>@bob have you met @carol@other.example yet</p>'))
|
||||
->toBe('have you met @carol@other.example yet');
|
||||
});
|
||||
|
||||
it('does not keep markup or script content', function () {
|
||||
expect(DirectMessageHandler::plainText('<p>hi<script>alert(1)</script> <b>there</b></p>'))->toBe('hi there');
|
||||
});
|
||||
|
||||
it('returns null when nothing is left', function () {
|
||||
expect(DirectMessageHandler::plainText('<p><span class="h-card"><a href="#">@<span>bob</span></a></span></p>'))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groups', function () {
|
||||
it('stores a note addressed to several people as one group conversation', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$carol = dmRemoteProfile('carol', 'other.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob, $dave, $carol]));
|
||||
|
||||
$conversation = DmConversation::first();
|
||||
|
||||
expect(DmConversation::count())->toBe(1)
|
||||
->and($conversation->type)->toBe('group')
|
||||
->and($conversation->participants_hash)->toBe(DmConversation::participantsHash([$alice->id, $bob->id, $dave->id, $carol->id]))
|
||||
->and(DmConversationParticipant::where('conversation_id', $conversation->id)->count())->toBe(4)
|
||||
->and(DmMessage::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('reads recipients from cc as well as to', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$carol = dmRemoteProfile('carol', 'other.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], ['cc' => [$carol->remote_url]]));
|
||||
|
||||
expect(DmConversation::first()->type)->toBe('group');
|
||||
});
|
||||
|
||||
it('never stores a group message as a followers-only post', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmFollow($bob, $alice);
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob, $dave], [
|
||||
'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']],
|
||||
]));
|
||||
|
||||
expect(Status::count())->toBe(0)
|
||||
->and(DmMessage::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('never stores a group message that replies to a local post as a comment', function () {
|
||||
$bobUser = dmLocalUser();
|
||||
$bob = dmProfile($bobUser);
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$post = Status::factory()->photo()->create(['profile_id' => $bob->id]);
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob, $dave], ['inReplyTo' => $post->url()]));
|
||||
|
||||
expect(Status::where('in_reply_to_id', $post->id)->count())->toBe(0)
|
||||
->and(DmMessage::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('drops a direct note that names nobody on this server instead of storing it as a post', function () {
|
||||
dmLocalUser();
|
||||
$alice = dmRemoteProfile();
|
||||
$carol = dmRemoteProfile('carol', 'other.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$carol], [
|
||||
'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']],
|
||||
]));
|
||||
|
||||
expect(Status::count())->toBe(0)
|
||||
->and(DmMessage::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('drops a note addressed to more people than a group allows', function () {
|
||||
config(['dm.groups.max_participants' => 3]);
|
||||
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$carol = dmRemoteProfile('carol', 'other.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob, $dave, $carol]));
|
||||
|
||||
expect(DmMessage::count())->toBe(0)
|
||||
->and(Status::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('hides the message from a member who blocks the sender without telling anyone', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
UserFilter::create([
|
||||
'user_id' => $dave->id,
|
||||
'filterable_id' => $alice->id,
|
||||
'filterable_type' => 'App\Models\Profile',
|
||||
'filter_type' => 'block',
|
||||
]);
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob, $dave]));
|
||||
|
||||
expect(DmMessage::count())->toBe(1)
|
||||
->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1)
|
||||
->and(DmConversationParticipant::where('profile_id', $dave->id)->value('unread_count'))->toBe(0)
|
||||
->and(DmConversationParticipant::where('profile_id', $dave->id)->value('last_activity_at'))->toBeNull()
|
||||
->and(Notification::where('profile_id', $dave->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('drops a message when the only person it could reach here has left the group', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$carol = dmRemoteProfile('carol', 'other.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob, $carol]));
|
||||
|
||||
DmConversationParticipant::where('profile_id', $bob->id)->update(['state' => 'left']);
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '2', [$bob, $carol]));
|
||||
|
||||
expect(DmMessage::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('starts a separate conversation when the set of people changes', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$dave = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($alice, dmNote($alice, '2', [$bob, $dave]));
|
||||
|
||||
expect(DmConversation::count())->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audience', function () {
|
||||
it('does not treat public, unlisted or followers-only notes as direct', function () {
|
||||
$alice = dmRemoteProfile();
|
||||
$bobUrl = 'https://pixelfed.test/users/bob';
|
||||
|
||||
expect(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => [$bobUrl]], $alice))->toBeFalse()
|
||||
->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$bobUrl], 'cc' => ['as:Public']], $alice))->toBeFalse()
|
||||
->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$alice->remote_url.'/followers'], 'cc' => [$bobUrl]], $alice))->toBeFalse()
|
||||
->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => ['https://friendica.example/followers/alice'], 'cc' => []], $alice))->toBeFalse()
|
||||
->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => $bobUrl], $alice))->toBeTrue()
|
||||
->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$bobUrl, 'https://other.example/users/carol']], $alice))->toBeTrue()
|
||||
->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => []], $alice))->toBeFalse();
|
||||
});
|
||||
|
||||
it('still counts a poll vote as a vote', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, [
|
||||
'id' => $alice->remote_url.'/votes/1',
|
||||
'type' => 'Note',
|
||||
'attributedTo' => $alice->remote_url,
|
||||
'name' => 'Option A',
|
||||
'inReplyTo' => 'https://remote.example/polls/1',
|
||||
'to' => [$bob->permalink()],
|
||||
]);
|
||||
|
||||
expect(DmMessage::count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threading', function () {
|
||||
it('links a reply to the message it answers', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($alice, dmNote($alice, '2', [$bob], ['inReplyTo' => $alice->remote_url.'/statuses/1']));
|
||||
|
||||
$first = DmMessage::whereObjectUri($alice->remote_url.'/statuses/1')->first();
|
||||
$second = DmMessage::whereObjectUri($alice->remote_url.'/statuses/2')->first();
|
||||
|
||||
expect($second->in_reply_to_id)->toBe($first->id)
|
||||
->and($second->conversation_id)->toBe($first->conversation_id);
|
||||
});
|
||||
|
||||
it('follows the remote thread when it moves to a new context', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($alice, dmNote($alice, '2', [$bob], [
|
||||
'context' => 'https://remote.example/contexts/2',
|
||||
'conversation' => 'tag:remote.example,2026-09-21:objectId=2:objectType=Conversation',
|
||||
]));
|
||||
|
||||
expect(DmConversation::count())->toBe(1)
|
||||
->and(DmConversation::first()->context_uri)->toBe('https://remote.example/contexts/2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edits', function () {
|
||||
it('updates the text when the author edits a message', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], ['content' => '<p>fixed typo</p>']), 'Update');
|
||||
|
||||
$message = DmMessage::first();
|
||||
|
||||
expect(DmMessage::count())->toBe(1)
|
||||
->and($message->body)->toBe('fixed typo')
|
||||
->and($message->edited_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an edit from someone who did not write the message', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$mallory = dmRemoteProfile('mallory', 'remote.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($mallory, dmNote($alice, '1', [$bob], ['content' => '<p>hijacked</p>']), 'Update');
|
||||
|
||||
expect(DmMessage::first()->body)->toBe('hello there');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletes', function () {
|
||||
it('removes a message when its author deletes it', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($alice, dmNote($alice, '2', [$bob]));
|
||||
|
||||
dmDeliver($alice, ['id' => $alice->remote_url.'/statuses/2', 'type' => 'Tombstone'], 'Delete');
|
||||
|
||||
$conversation = DmConversation::first();
|
||||
$first = DmMessage::whereObjectUri($alice->remote_url.'/statuses/1')->first();
|
||||
|
||||
expect(DmMessage::count())->toBe(1)
|
||||
->and($conversation->last_message_id)->toBe($first->id)
|
||||
->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1)
|
||||
->and(DB::table('dm_message_media')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('removes the media of a deleted message', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob], [
|
||||
'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']],
|
||||
]));
|
||||
|
||||
expect(Media::count())->toBe(1);
|
||||
|
||||
dmDeliver($alice, ['id' => $alice->remote_url.'/statuses/1', 'type' => 'Tombstone'], 'Delete');
|
||||
|
||||
expect(DmMessage::count())->toBe(0)
|
||||
->and(DB::table('dm_message_media')->count())->toBe(0);
|
||||
|
||||
Queue::assertPushed(MediaDeletePipeline::class, 1);
|
||||
});
|
||||
|
||||
it('ignores a delete from someone who did not write the message', function () {
|
||||
$bob = dmProfile(dmLocalUser());
|
||||
$alice = dmRemoteProfile();
|
||||
$mallory = dmRemoteProfile('mallory', 'remote.example');
|
||||
dmSeedHosts();
|
||||
|
||||
dmDeliver($alice, dmNote($alice, '1', [$bob]));
|
||||
dmDeliver($mallory, ['id' => $alice->remote_url.'/statuses/1', 'type' => 'Tombstone'], 'Delete');
|
||||
|
||||
expect(DmMessage::count())->toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
use App\Models\DmConversation;
|
||||
use App\Models\DmConversationParticipant;
|
||||
use App\Models\DmMessage;
|
||||
use App\Models\Media;
|
||||
use App\Models\Report;
|
||||
use App\Services\DirectMessageService;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
require_once __DIR__.'/helpers.php';
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Legacy thread endpoints and the Mastodon conversations API
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The Blade UI and the older mobile app address a thread by the other
|
||||
| person's profile id. Those endpoints keep their request and response
|
||||
| shapes and now read and write conversations.
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
|
||||
$this->withoutMiddleware(ThrottleRequests::class);
|
||||
|
||||
config([
|
||||
'instance.enable_cc' => false,
|
||||
'federation.activitypub.enabled' => true,
|
||||
'snowflake.datacenter_id' => 1,
|
||||
'snowflake.worker_id' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('thread endpoints', function () {
|
||||
it('sends through thread/send and reads it back through thread', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$sent = $this->postJson('/api/v1.1/direct/thread/send', [
|
||||
'to_id' => $bob->profile_id,
|
||||
'message' => 'hello bob',
|
||||
'type' => 'text',
|
||||
]);
|
||||
|
||||
$sent->assertOk()
|
||||
->assertJsonPath('isAuthor', true)
|
||||
->assertJsonPath('type', 'text')
|
||||
->assertJsonPath('text', 'hello bob');
|
||||
|
||||
expect($sent->json('reportId'))->toBe($sent->json('id'))
|
||||
->and(DmConversation::count())->toBe(1);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$thread = $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id);
|
||||
|
||||
$thread->assertOk()
|
||||
->assertJsonPath('id', (string) $alice->profile_id)
|
||||
->assertJsonCount(1, 'messages')
|
||||
->assertJsonPath('messages.0.text', 'hello bob')
|
||||
->assertJsonPath('messages.0.isAuthor', false)
|
||||
->assertJsonPath('messages.0.seen', false);
|
||||
|
||||
expect($thread->json('conversation_id'))->toBe((string) DmConversation::first()->id);
|
||||
});
|
||||
|
||||
it('returns an empty thread for someone never messaged', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)
|
||||
->assertOk()
|
||||
->assertJsonCount(0, 'messages')
|
||||
->assertJsonPath('conversation_id', null);
|
||||
});
|
||||
|
||||
it('uploads media with text as a single message', function () {
|
||||
Storage::fake(config('filesystems.default'));
|
||||
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->post('/api/v1.1/direct/thread/media', [
|
||||
'to_id' => $bob->profile_id,
|
||||
'file' => UploadedFile::fake()->image('cat.jpg', 100, 100),
|
||||
'message' => 'my cat',
|
||||
], ['Accept' => 'application/json'])->assertOk()->assertJsonPath('type', 'photo');
|
||||
|
||||
$message = DmMessage::with('media')->first();
|
||||
|
||||
expect(DmMessage::count())->toBe(1)
|
||||
->and($message->body)->toBe('my cat')
|
||||
->and($message->media)->toHaveCount(1)
|
||||
->and($message->media[0]->status_id)->toBeNull();
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)
|
||||
->assertJsonPath('messages.0.text', 'my cat')
|
||||
->assertJsonPath('messages.0.type', 'photo')
|
||||
->assertJsonCount(1, 'messages.0.carousel');
|
||||
|
||||
expect($this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)->json('messages.0.media'))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('marks a thread read and reports it as seen to the sender', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'one', 'type' => 'text'])->json('id');
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->postJson('/api/v1.1/direct/thread/read', ['pid' => $alice->profile_id, 'sid' => $id])
|
||||
->assertOk()
|
||||
->assertExactJson([$id]);
|
||||
|
||||
expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(0);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)->assertJsonPath('messages.0.seen', true);
|
||||
});
|
||||
|
||||
it('deletes by the id the thread handed out', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'oops', 'type' => 'text'])->json('reportId');
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->deleteJson('/api/v1.1/direct/thread/message', ['id' => $id])->assertNotFound();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$this->deleteJson('/api/v1.1/direct/thread/message', ['id' => $id])->assertOk();
|
||||
|
||||
expect(DmMessage::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('mutes and unmutes a thread', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->postJson('/api/v1.1/direct/thread/mute', ['id' => $bob->profile_id])->assertOk();
|
||||
expect(DmConversationParticipant::where('profile_id', $alice->profile_id)->value('muted_at'))->not->toBeNull();
|
||||
|
||||
$this->postJson('/api/v1.1/direct/thread/unmute', ['id' => $bob->profile_id])->assertOk();
|
||||
expect(DmConversationParticipant::where('profile_id', $alice->profile_id)->value('muted_at'))->toBeNull();
|
||||
});
|
||||
|
||||
it('lets an older client report a message as a post', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
$id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'spam', 'type' => 'text'])->json('reportId');
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
$this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'post', 'object_id' => $id])->assertOk();
|
||||
|
||||
expect(Report::first()->object_type)->toBe(DmMessage::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/conversations', function () {
|
||||
it('lists one to one conversations with a status shaped last message', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
||||
$message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1/conversations')
|
||||
->assertOk()
|
||||
->assertJsonCount(1)
|
||||
->assertJsonPath('0.id', (string) $conversation->id)
|
||||
->assertJsonPath('0.unread', true)
|
||||
->assertJsonPath('0.accounts.0.id', (string) $alice->profile_id)
|
||||
->assertJsonPath('0.last_status.id', (string) $message->id)
|
||||
->assertJsonPath('0.last_status.visibility', 'direct')
|
||||
->assertJsonPath('0.last_status.content', '<p>hello</p>')
|
||||
->assertJsonPath('0.last_status.account.id', (string) $alice->profile_id);
|
||||
|
||||
$this->postJson("/api/v1/conversations/{$conversation->id}/read")->assertOk()->assertJsonPath('unread', false);
|
||||
$this->deleteJson("/api/v1/conversations/{$conversation->id}")->assertOk();
|
||||
$this->getJson('/api/v1/conversations')->assertJsonCount(0);
|
||||
});
|
||||
|
||||
it('only includes groups when asked', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
$dave = dmLocalUser();
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
$group = $service->findOrCreateConversation(dmProfile($alice), collect([dmProfile($bob), dmProfile($dave)]));
|
||||
$service->sendMessage($group, dmProfile($alice), ['body' => 'hi all']);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1/conversations')->assertJsonCount(0);
|
||||
$this->getJson('/api/v1/conversations?include_groups=1')
|
||||
->assertJsonCount(1)
|
||||
->assertJsonCount(2, '0.accounts');
|
||||
});
|
||||
|
||||
it('keeps requests out of the inbox', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser(publicDm: false);
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
||||
$service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']);
|
||||
|
||||
Passport::actingAs($bob, ['read', 'write']);
|
||||
|
||||
$this->getJson('/api/v1/conversations')->assertJsonCount(0);
|
||||
$this->getJson('/api/v1/conversations?scope=requests')->assertJsonCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media housekeeping', function () {
|
||||
it('does not let direct message media be attached to a post', function () {
|
||||
$alice = dmLocalUser();
|
||||
$bob = dmLocalUser();
|
||||
|
||||
$media = Media::create([
|
||||
'status_id' => null,
|
||||
'profile_id' => $alice->profile_id,
|
||||
'user_id' => $alice->id,
|
||||
'media_path' => 'public/m/test.jpg',
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => 1000,
|
||||
]);
|
||||
|
||||
$service = app(DirectMessageService::class);
|
||||
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
||||
$service->sendMessage($conversation, dmProfile($alice), ['media' => collect([$media])]);
|
||||
|
||||
Passport::actingAs($alice, ['read', 'write']);
|
||||
|
||||
$this->postJson('/api/v1/statuses', ['media_ids' => [$media->id], 'status' => 'now public'])->assertStatus(400);
|
||||
|
||||
expect($media->fresh()->status_id)->toBeNull();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Follower;
|
||||
use App\Models\Profile;
|
||||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
use App\Util\ActivityPub\Inbox;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
if (! function_exists('dmLocalUser')) {
|
||||
/**
|
||||
* A local user. By default they take messages from everyone, so a test
|
||||
* about requests has to ask for that explicitly.
|
||||
*/
|
||||
function dmLocalUser(bool $publicDm = true, array $attributes = []): User
|
||||
{
|
||||
$user = User::factory()->create(array_merge(['created_at' => now()->subYear()], $attributes));
|
||||
$user->refresh();
|
||||
|
||||
UserSetting::updateOrCreate(['user_id' => $user->id], ['public_dm' => $publicDm]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function dmProfile(User $user): Profile
|
||||
{
|
||||
return Profile::findOrFail($user->profile_id);
|
||||
}
|
||||
|
||||
function dmRemoteProfile(string $username = 'alice', string $domain = 'remote.example'): Profile
|
||||
{
|
||||
$actor = "https://{$domain}/users/{$username}";
|
||||
|
||||
return Profile::factory()->remote()->create([
|
||||
'domain' => $domain,
|
||||
'username' => "@{$username}@{$domain}",
|
||||
'remote_url' => $actor,
|
||||
'key_id' => "{$actor}#main-key",
|
||||
'inbox_url' => "{$actor}/inbox",
|
||||
'sharedInbox' => "https://{$domain}/inbox",
|
||||
'last_fetched_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the DNS and banned-domain caches so URL validation passes without
|
||||
* a network lookup. Call after factories, the lazy refresh can flush the
|
||||
* cache.
|
||||
*/
|
||||
function dmSeedHosts(array $hosts = ['remote.example', 'other.example']): void
|
||||
{
|
||||
$hosts[] = config('pixelfed.domain.app');
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.40'], 3600);
|
||||
}
|
||||
|
||||
Cache::put('instances:banned:domains', [], 1209600);
|
||||
}
|
||||
|
||||
function dmFollow(Profile $follower, Profile $target): void
|
||||
{
|
||||
Follower::create([
|
||||
'profile_id' => $follower->id,
|
||||
'following_id' => $target->id,
|
||||
'local_profile' => $follower->domain === null,
|
||||
'local_following' => $target->domain === null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A direct Note the way Mastodon sends one: addressed to people only,
|
||||
* a Mention for each of them, and the thread identifiers.
|
||||
*
|
||||
* @param array<int, Profile> $recipients
|
||||
*/
|
||||
function dmNote(Profile $author, string $path, array $recipients, array $overrides = []): array
|
||||
{
|
||||
$id = $author->remote_url.'/statuses/'.$path;
|
||||
$to = array_map(fn (Profile $profile) => $profile->permalink(), $recipients);
|
||||
$mentions = implode(' ', array_map(
|
||||
fn (Profile $profile) => '<span class="h-card"><a href="'.$profile->permalink().'" class="u-url mention">@<span>'.ltrim(explode('@', ltrim($profile->username, '@'))[0], '@').'</span></a></span>',
|
||||
$recipients
|
||||
));
|
||||
|
||||
return array_merge([
|
||||
'id' => $id,
|
||||
'type' => 'Note',
|
||||
'attributedTo' => $author->remote_url,
|
||||
'url' => "https://{$author->domain}/@user/{$path}",
|
||||
'content' => "<p>{$mentions} hello there</p>",
|
||||
'published' => now()->subMinute()->toAtomString(),
|
||||
'inReplyTo' => null,
|
||||
'to' => $to,
|
||||
'cc' => [],
|
||||
'sensitive' => false,
|
||||
'conversation' => "tag:{$author->domain},2026-09-21:objectId=1:objectType=Conversation",
|
||||
'context' => "https://{$author->domain}/contexts/1",
|
||||
'attachment' => [],
|
||||
'tag' => array_map(fn (Profile $profile) => [
|
||||
'type' => 'Mention',
|
||||
'href' => $profile->permalink(),
|
||||
'name' => '@'.ltrim($profile->username, '@'),
|
||||
], $recipients),
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function dmDeliver(Profile $actor, array $object, string $type = 'Create'): void
|
||||
{
|
||||
$payload = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $object['id'].'/activity',
|
||||
'type' => $type,
|
||||
'actor' => $actor->remote_url,
|
||||
'object' => $object,
|
||||
];
|
||||
|
||||
if ($type === 'Create') {
|
||||
$payload['to'] = $object['to'] ?? [];
|
||||
$payload['cc'] = $object['cc'] ?? [];
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'signature' => ['keyId="'.$actor->key_id.'",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="dGVzdA=="'],
|
||||
'date' => [now()->toRfc7231String()],
|
||||
];
|
||||
|
||||
(new Inbox($headers, null, $payload))->handle();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue