# 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 = '

'; $tag_np = '

'; $tag_li = '
  • '; $tag_nli = '
  • '; $tag_ul = ''; } my $options = $tag_br . $tag_br . $tag_ul; if ($is_html) { $vars->{'closelink'} = ''; $options .= join( '', map { my $key = $_; $vars->{'openlink'} = ''; $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;