mourningdove/cgi-bin/LJ/Event.pm

657 lines
19 KiB
Perl
Raw Permalink Normal View History

2026-05-24 01:03:05 +00:00
# This code was forked from the LiveJournal project owned and operated
# by Live Journal, Inc. The code has been modified and expanded by
# Dreamwidth Studios, LLC. These files were originally licensed under
# the terms of the license supplied by Live Journal, Inc, which can
# currently be found at:
#
# http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt
#
# In accordance with the original license, this code and all its
# modifications are provided under the GNU General Public License.
# A copy of that license can be found in the LICENSE file included as
# part of this distribution.
package LJ::Event;
use strict;
use v5.10;
no warnings 'uninitialized';
use Log::Log4perl;
my $log = Log::Log4perl->get_logger(__PACKAGE__);
use Carp qw(croak);
use DW::Task::ESN::FiredEvent;
use DW::TaskQueue;
use LJ::ModuleLoader;
use LJ::ESN;
use LJ::Subscription;
use LJ::Typemap;
my @EVENTS = LJ::ModuleLoader->module_subclasses("LJ::Event");
foreach my $event (@EVENTS) {
eval "use $event";
die "Error loading event module '$event': $@" if $@;
}
# Guide to subclasses:
# LJ::Event::JournalNewEntry -- a journal (user/community) has a new entry in it
# ($ju,$ditemid,undef)
# LJ::Event::JournalNewComment -- a journal has a new comment in it
# ($ju,$jtalkid) # TODO: should probably be ($ju,$jitemid,$jtalkid)
# LJ::Event::JournalNewComment::TopLevel -- a journal has a new top-level comment in it
# ($ju,$jitemid)
# LJ::Event::JournalNewComment::Reply -- reply to your own comment/entry or reply by you
# ($ju,$jtalkid)
# LJ::Event::AddedToCircle -- user $fromuserid added $u to their circle; $actionid is 1 (trust) or 2 (watch)
# ($u,$fromuserid,$actionid)
# LJ::Event::RemovedFromCircle -- user $fromuserid removed $u to their circle; $actionid is 1 (trust) or 2 (watch)
# ($u,$fromuserid,$actionid)
# LJ::Event::CommunityInvite -- user $fromuserid invited $u to join $commid community)
# ($u,$fromuserid, $commid)
# LJ::Event::InvitedFriendJoins -- user $u1 was invited to join by $u2 and created a journal
# ($u1, $u2)
# LJ::Event::NewUserpic -- user $u uploaded userpic $up
# ($u,$up)
# LJ::Event::UserExpunged -- user $u is expunged
# ($u)
# LJ::Event::Birthday -- user $u's birthday
# ($u)
# LJ::Event::PollVote -- $u1 voted in poll $p posted by $u
# ($u, $u1, $up)
# LJ::Event::UserMessageRecvd -- user $u received message with ID $msgid from user $otherid
# ($u, $msgid, $otherid)
# LJ::Event::UserMessageSent -- user $u sent message with ID $msgid to user $otherid
# ($u, $msgid, $otherid)
# LJ::Event::ImportStatus -- user $u has received an import status notification
# ($u, $item, $hashref)
sub new {
my ( $class, $u, @args ) = @_;
croak("too many args") if @args > 2;
croak("args must be numeric") if grep { /\D/ } @args;
croak("u isn't a user") unless LJ::isu($u);
return bless {
userid => $u->id,
args => \@args,
}, $class;
}
sub arg_list {
return ( "Arg 1", "Arg 2" );
}
# Class method
sub new_from_raw_params {
my ( undef, $etypeid, $journalid, $arg1, $arg2 ) = @_;
my $class = LJ::Event->class($etypeid) or die "Classname cannot be undefined/false";
my $journal = LJ::load_userid($journalid) or die "Invalid journalid $journalid";
my $evt = LJ::Event->new( $journal, $arg1, $arg2 );
# bless into correct class
bless $evt, $class;
return $evt;
}
sub raw_params {
my $self = shift;
use Data::Dumper;
my $ju = $self->event_journal
or Carp::confess( "Event $self has no journal: " . Dumper($self) );
my @params =
map { $_ + 0 } ( $self->etypeid, $ju->{userid}, $self->{args}[0], $self->{args}[1] );
return wantarray ? @params : \@params;
}
# You can override this if you need, but shouldn't have to.
sub configure_logger {
my $self = $_[0];
Log::Log4perl::MDC->put( "evt_class", $self->class );
Log::Log4perl::MDC->put( "evt_user", $self->u->user );
Log::Log4perl::MDC->put( "evt_userid", $self->u->id );
Log::Log4perl::MDC->put( "evt_arg1", $self->arg1 );
Log::Log4perl::MDC->put( "evt_arg2", $self->arg2 );
}
# Override this. by default, events are rare, so subscriptions to
# them are tracked in target's "has_subscription" table.
# for common events, change this to '1' in subclasses and events
# will always fire without consulting the "has_subscription" table
sub is_common {
0;
}
# Override this with a false value if subscriptions to this event should
# not show up in normal UI
sub is_visible { 1 }
# Override this with a true if notification to this event should be sent
# even if user account is not in active state.
sub is_significant { 0 }
# Whether Inbox is always subscribed to
sub always_checked { 0 }
# Override this with HTML containing the actual event
sub content { '' }
# Override this with HTML containing a summary of the event text (may be left blank)
sub content_summary { '' }
# Override this to provide details, method for XMLRPC::getinbox
sub raw_info {
my $self = shift;
my $subclass = ref $self;
$subclass =~ s/LJ::Event:?:?//;
return { type => $subclass };
}
sub as_string {
my ( $self, $u ) = @_;
croak "No target passed to Event->as_string" unless LJ::isu($u);
my ($classname) = ( ref $self ) =~ /Event::(.+?)$/;
return "Event $classname fired for user=$u->{user}, args=[@{$self->{args}}]";
}
# default is just return the string, override if subclass
# actually can generate pretty content
sub as_html {
my ( $self, $u ) = @_;
croak "No target passed to Event->as_string" unless LJ::isu($u);
return $self->as_string;
}
# plaintext email subject
sub as_email_subject {
my ( $self, $u ) = @_;
return $self->as_string($u);
}
# contents for HTML email
sub as_email_html {
my ( $self, $u ) = @_;
return $self->as_email_string($u);
}
# contents for plaintext email
sub as_email_string {
my ( $self, $u ) = @_;
return $self->as_string($u);
}
# the "From" line for email
sub as_email_from_name {
my ( $self, $u ) = @_;
return $LJ::SITENAMESHORT;
}
# Optional headers (for comment notifications)
sub as_email_headers {
my ( $self, $u ) = @_;
return undef;
}
# class method, takes a subscription
sub subscription_as_html {
my ( $class, $subscr ) = @_;
croak "No subscription" unless $subscr;
my $arg1 = $subscr->arg1;
my $arg2 = $subscr->arg2;
my $journalid = $subscr->journalid;
my $user = $journalid ? LJ::ljuser( LJ::load_userid($journalid) ) : "(wildcard)";
return $class . " arg1: $arg1 arg2: $arg2 user: $user";
}
# override in subclasses
sub subscription_applicable {
my ( $class, $subscr ) = @_;
return 1;
}
# can $u subscribe to this event?
sub available_for_user {
my ( $class, $u, $subscr ) = @_;
return 1;
}
# Quick way to bypass during subscription lookup
sub early_filter_event {
# arguments: ($class,$evt) = @_;
return 1;
}
# additional SQL for subscriptions
# does not need to be prefixed with 'AND'
sub additional_subscriptions_sql {
# arguments: ($class,$evt) = @_;
return ('');
}
# valid values are nothing ("" or undef), "all", or "friends"
sub zero_journalid_subs_means {
# arguments: ($class,$evt) = @_;
return '';
}
############################################################################
# Don't override
############################################################################
sub event_journal { &u; }
sub u { LJ::load_userid( $_[0]->{userid} ) }
sub arg1 { $_[0]->{args}[0] }
sub arg2 { $_[0]->{args}[1] }
# class method
# Drains all ESN tasks from the TaskQueue synchronously. Each phase of the ESN
# pipeline can enqueue tasks for the next phase, so we loop until all four
# queues are empty.
sub process_fired_events {
my $class = shift;
croak("Can't call in web context") if LJ::is_web_context();
my $tsq = DW::TaskQueue->get;
my @esn_classes = qw(
DW::Task::ESN::FiredEvent
DW::Task::ESN::FindSubsByCluster
DW::Task::ESN::FilterSubs
DW::Task::ESN::ProcessSub
);
my $found = 1;
while ($found) {
$found = 0;
foreach my $task_class (@esn_classes) {
while (1) {
my $messages = $tsq->receive( $task_class, 100, 0 );
last unless @{ $messages || [] };
$found = 1;
foreach my $pair (@$messages) {
my ( $handle, $task ) = @$pair;
$task->work($handle);
$tsq->completed( $task_class, $handle );
}
}
}
}
}
# instance method.
# fire either logs the event to the delayed work system to be
# processed later, or does nothing, if it's a rare event and there
# are no subscriptions for the event.
sub fire {
# The TaskQueue knows how to convert us to an appropriate job or task and
# schedule is in the correct place.
return DW::TaskQueue->dispatch( $_[0] ) ? 1 : 0;
}
sub fire_task {
my $self = $_[0];
return unless $self->should_enqueue;
return DW::Task::ESN::FiredEvent->new( $self->raw_params );
}
sub subscriptions {
my ( $self, %args ) = @_;
my $cid_in = delete $args{'cluster'}; # optional
my $limit = delete $args{'limit'}; # optional
my $scratch = {};
croak( "Unknown options: " . join( ', ', keys %args ) ) if %args;
croak("Can't call in web context") if LJ::is_web_context();
$scratch->{limit_remain} = $limit;
my @subs;
my @event_classes = grep { $_->early_filter_event($self) } $self->related_event_classes;
foreach my $cid ( $cid_in ? ($cid_in) : @LJ::CLUSTERS ) {
last if $limit && $scratch->{limit_remain} <= 0;
foreach my $class (@event_classes) {
last if $limit && $scratch->{limit_remain} <= 0;
my $etypeid = $class->etypeid;
$scratch->{"evt:$etypeid"} //= {};
push @subs, $class->raw_subscriptions( $self, scratch => $scratch, cluster => $cid );
}
}
return @subs;
}
sub raw_subscriptions {
my ( $class, $self, %args ) = @_;
my $cid = delete $args{'cluster'};
croak("Cluser id (cluster) must be provided") unless defined $cid;
my $scratch = delete $args{'scratch'} || {}; # optional
croak( "Unknown options: " . join( ', ', keys %args ) ) if %args;
croak("Can't call in web context") if LJ::is_web_context();
# allsubs
my @subs;
my $etypeid = $class->etypeid;
my $evt_scratch = $scratch->{"evt:$etypeid"} // {};
my $limit_remain = $scratch->{limit_remain};
my $and_enabled =
"AND flags & " . ( LJ::Subscription->INACTIVE | LJ::Subscription->DISABLED ) . " = 0";
return if defined $limit_remain && $limit_remain <= 0;
my $allmatch = 0;
my $zeromeans;
my ( $addl_sql, @addl_args );
my @wildcards_from;
if ( defined $evt_scratch->{allmatch} ) {
$zeromeans = $evt_scratch->{zeromeans};
$allmatch = $evt_scratch->{allmatch};
@wildcards_from = @{ $evt_scratch->{wildcards_from} };
$addl_sql = $evt_scratch->{addl_sql};
@addl_args = @{ $evt_scratch->{addl_args} };
}
else {
$zeromeans = $class->zero_journalid_subs_means($self);
( $addl_sql, @addl_args ) = $class->additional_subscriptions_sql($self);
$addl_sql = " AND ( $addl_sql )" if $addl_sql;
if ( $zeromeans eq 'trusted' ) {
@wildcards_from = $self->u->trusted_by_userids;
}
elsif ( $zeromeans eq 'watched' ) {
@wildcards_from = $self->u->watched_by_userids;
}
elsif ( $zeromeans eq 'trusted_or_watched' ) {
my %unique_ids =
map { $_ => 1 } ( $self->u->trusted_by_userids, $self->u->watched_by_userids );
@wildcards_from = keys %unique_ids;
}
elsif ( $zeromeans eq 'all' ) {
$allmatch = 1;
}
$evt_scratch->{zeromeans} = $zeromeans;
$evt_scratch->{allmatch} = $allmatch;
$evt_scratch->{wildcards_from} = \@wildcards_from;
$evt_scratch->{addl_sql} = $addl_sql;
$evt_scratch->{addl_args} = \@addl_args;
}
my $dbcm = LJ::get_cluster_master($cid)
or die;
# first we find exact matches (or all matches)
my $journal_match = $allmatch ? "" : "AND journalid=?";
my $limit_sql = $limit_remain ? "LIMIT $limit_remain" : '';
my $sql =
"SELECT userid, subid, is_dirty, journalid, etypeid, "
. "arg1, arg2, ntypeid, createtime, expiretime, flags "
. "FROM subs WHERE etypeid = ? $journal_match $and_enabled $addl_sql $limit_sql";
my $sth = $dbcm->prepare($sql);
my @args = ($etypeid);
push @args, $self->u->id unless $allmatch;
$sth->execute( @args, @addl_args );
if ( $sth->err ) {
warn "SQL: [$sql], args=[@args], addl_args=[@addl_args]\n";
die $sth->errstr;
}
while ( my $row = $sth->fetchrow_hashref ) {
push @subs, LJ::Subscription->new_from_row($row);
}
# then we find wildcard matches.
if (@wildcards_from) {
# FIXME: journals are only on one cluster! split jidlist based on cluster
my $jidlist = join( ",", @wildcards_from );
my $sth =
$dbcm->prepare( "SELECT userid, subid, is_dirty, journalid, etypeid, "
. "arg1, arg2, ntypeid, createtime, expiretime, flags "
. "FROM subs USE INDEX(PRIMARY) WHERE etypeid = ? AND journalid=0 $and_enabled AND userid IN ($jidlist) $addl_sql"
);
$sth->execute( $etypeid, @addl_args );
die $sth->errstr if $sth->err;
while ( my $row = $sth->fetchrow_hashref ) {
push @subs, LJ::Subscription->new_from_row($row);
}
}
$limit_remain -= @subs;
$scratch->{limit_remain} = $limit_remain
if defined $scratch->{limit_remain};
return @subs;
}
# helper method to be called when overriding parent's raw_subscriptions
# method to always return a subscription object for the user
sub _raw_always_subscribed {
my ( $class, $self, %args ) = @_;
my $cid = delete $args{'cluster'};
croak("Cluser id (cluster) must be provided") unless defined $cid;
my $scratch = delete $args{'scratch'}; # optional
# hash keys specific to this helper method
my $skip_parent = delete $args{'skip_parent'}; # optional
my $ntypeid = delete $args{'ntypeid'};
croak("Failed to provide ntypeid") unless defined $ntypeid;
croak( "Unknown options: " . join( ', ', keys %args ) ) if %args;
croak("Can't call in web context") if LJ::is_web_context();
my @subs;
my $u = $self->u;
return unless $cid == $u->clusterid;
my $row = {
userid => $self->u->id,
ntypeid => $ntypeid,
etypeid => $class->etypeid,
};
push @subs, LJ::Subscription->new_from_row($row);
push @subs,
eval { LJ::Event::raw_subscriptions( $class, $self, cluster => $cid, scratch => $scratch ) }
unless $skip_parent;
return @subs;
}
# INSTANCE METHOD: SHOULD OVERRIDE if the subscriptions support filtering
sub matches_filter {
my ( $self, $subsc ) = @_;
return 0 unless $subsc->available_for_user;
return 1;
}
# instance method. Override if possible.
# returns when the event happened, or undef if unknown
sub eventtime_unix {
return undef;
}
# instance method
sub should_enqueue {
my $self = shift;
return 1; # for now.
return $self->is_common || $self->has_subscriptions;
}
# instance method
# Override this to have notifications for an event show up as read
sub mark_read {
my $self = shift;
return 0;
}
# instance method
sub has_subscriptions {
my $self = shift;
return 1; # FIXME: consult "has_subs" table
}
sub get_subscriptions {
my ( $self, $u, $subid ) = @_;
return LJ::Subscription->new_by_id( $u, $subid );
}
# get the typemap for the subscriptions classes (class/instance method)
sub typemap {
return LJ::Typemap->new(
table => 'eventtypelist',
classfield => 'class',
idfield => 'etypeid',
);
}
# returns the class name, given an etypid
sub class {
my ( $class, $typeid ) = @_;
my $tm = $class->typemap
or return undef;
$typeid ||= $class->etypeid;
return $tm->typeid_to_class($typeid);
}
# returns the eventtypeid for this site.
# don't override this in subclasses.
sub etypeid {
my ($class_self) = @_;
my $class = ref $class_self ? ref $class_self : $class_self;
my $tm = $class->typemap
or return undef;
return $tm->class_to_typeid($class);
}
# return a list of related events, for considering as one group
# to avoid dupes when processing subs
# list includes your own etypeid
sub related_event_classes {
return $_[0];
}
sub related_events {
return return map { $_->etypeid } $_[0]->related_event_classes;
}
# Class method
sub event_to_etypeid {
my ( $class, $evt_name ) = @_;
$evt_name = "LJ::Event::$evt_name" unless $evt_name =~ /^LJ::Event::/;
my $tm = $class->typemap
or return undef;
return $tm->class_to_typeid($evt_name);
}
# this returns a list of all possible event classes
# class method
sub all_classes {
my $class = shift;
# return config'd classes if they exist, otherwise just return everything that has a mapping
return @LJ::EVENT_TYPES if @LJ::EVENT_TYPES;
croak "all_classes is a class method" unless $class;
my $tm = $class->typemap
or croak "Bad class $class";
return $tm->all_classes;
}
sub format_options {
my ( $self, $is_html, $lang, $vars, $urls, $extra ) = @_;
my ( $tag_p, $tag_np, $tag_li, $tag_nli, $tag_ul, $tag_nul, $tag_br ) =
( '', '', '', '', '', '', "\n" );
if ($is_html) {
$tag_p = '<p>';
$tag_np = '</p>';
$tag_li = '<li>';
$tag_nli = '</li>';
$tag_ul = '<ul>';
$tag_nul = '</ul>';
}
my $options = $tag_br . $tag_br . $tag_ul;
if ($is_html) {
$vars->{'closelink'} = '</a>';
$options .= join(
'',
map {
my $key = $_;
$vars->{'openlink'} = '<a href="' . $urls->{$key}->[1] . '">';
$tag_li . LJ::Lang::get_text( $lang, $key, undef, $vars ) . $tag_nli;
}
sort { $urls->{$a}->[0] <=> $urls->{$b}->[0] }
grep { $urls->{$_}->[0] }
keys %$urls
);
}
else {
$vars->{'openlink'} = '';
$vars->{'closelink'} = '';
$options .= join(
'',
map {
my $key = $_;
' - '
. LJ::Lang::get_text( $lang, $key, undef, $vars ) . ":\n" . ' '
. $urls->{$key}->[1] . "\n"
}
sort { $urls->{$a}->[0] <=> $urls->{$b}->[0] }
grep { $urls->{$_}->[0] }
keys %$urls
);
chomp($options);
}
$options .= $extra if $extra;
$options .= $tag_nul . $tag_br;
return $options;
}
1;