mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
315 lines
11 KiB
PHP
315 lines
11 KiB
PHP
<?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;
|
|
}
|
|
}
|