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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion includes/class-slack.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ public static function init(): void {
}

/**
* Register always-on admin REST routes and the bot user's avatar.
* Register always-on admin REST routes, the bot user's avatar and the
* Slack handle field on user profiles.
*/
public static function register_hooks(): void {
add_action( 'rest_api_init', [ self::get_webhook_controller(), 'register_admin_routes' ] );
Slack_Author_Resolver::init();
// After Simple Local Avatars, so the bundled avatar replaces its fallback but not an uploaded avatar.
add_filter( 'pre_get_avatar_data', [ Slack_Config::class, 'filter_bot_avatar' ], 20, 2 );
}
Expand Down
226 changes: 224 additions & 2 deletions includes/slack/class-slack-author-resolver.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Slack author resolution and entry author display filtering.
* Slack author resolution.
*
* @package Newspack_Rolling_Coverage
*/
Expand All @@ -10,10 +10,34 @@
defined( 'ABSPATH' ) || exit;

/**
* Resolves Slack users to WordPress authors and filters entry author display.
* Resolves Slack users to WordPress authors.
*
* A WordPress user can enter their Slack handle or member ID on their
* profile. Entries from Slack messages that person posts are then credited
* to them rather than to the generic Slack bot user.
*/
class Slack_Author_Resolver {

/**
* User meta holding the Slack handle or member ID mapped to a WordPress user.
*/
const META_SLACK_HANDLE = 'rolling_coverage_slack_handle';

/**
* Name of the Slack handle or member ID field on the profile form.
*/
const FIELD_SLACK_HANDLE = 'rolling_coverage_slack_handle';

/**
* Register the Slack handle field on the profile and user edit screens.
*/
public static function init(): void {
add_action( 'show_user_profile', [ __CLASS__, 'render_profile_section' ] );
add_action( 'edit_user_profile', [ __CLASS__, 'render_profile_section' ] );
add_action( 'personal_options_update', [ __CLASS__, 'save_profile_section' ] );
add_action( 'edit_user_profile_update', [ __CLASS__, 'save_profile_section' ] );
}

/**
* Get or create the Slack bot WordPress user ID.
*
Expand All @@ -22,4 +46,202 @@ class Slack_Author_Resolver {
public static function get_slack_bot_user_id(): int {
return Slack_Config::get_or_create_bot_user_id();
}

/**
* The WordPress user to credit with a Slack message, and what matched:
* the user whose stored member ID or handle matches the message's author,
* or the Slack bot user when nobody's does.
*
* The member ID comes with the message, so it matches even when the
* author's profile could not be read, and only that ID is compared with
* stored member IDs. The author's profile names are compared with stored
* handles only: a name is free text its owner can change, so a name
* shaped like someone's member ID must never reach that person's mapping.
* Slack shows a person's display name, or their full name when the
* display name is empty; the legacy username comes last. Only users who
* can write entries are credited.
*
* @param string $slack_user_id The author's Slack member ID.
* @param array|\WP_Error $user_info The author's `users.info` profile.
* @return array{user_id: int, matched_by: string} The user ID (0 when the
* bot user is unavailable)
* and `member_id`,
* `display_name`,
* `real_name`, `name` or
* `bot`.
*/
public static function resolve_author( string $slack_user_id, array|\WP_Error $user_info ): array {
$candidates = [ 'member_id' => $slack_user_id ];

if ( is_array( $user_info ) ) {
$candidates['display_name'] = $user_info['profile']['display_name'] ?? '';
$candidates['real_name'] = $user_info['profile']['real_name'] ?? '';
$candidates['name'] = $user_info['name'] ?? '';
}

foreach ( $candidates as $matched_by => $candidate ) {
$candidate = is_string( $candidate ) ? self::normalize_handle( $candidate ) : '';

if ( '' === $candidate ) {
continue;
}

$user_ids = array_filter(
self::find_user_ids( $candidate, 'member_id' === $matched_by ),
fn( $user_id ) => user_can( $user_id, 'edit_posts' )
);

if ( ! empty( $user_ids ) ) {
return [
'user_id' => (int) reset( $user_ids ),
'matched_by' => $matched_by,
];
}
}

return [
'user_id' => self::get_slack_bot_user_id(),
'matched_by' => 'bot',
];
}

/**
* A handle as stored and compared: trimmed, without the leading `@`
* people tend to type.
*
* @param string $handle Slack handle.
* @return string
*/
public static function normalize_handle( string $handle ): string {
return trim( ltrim( trim( $handle ), '@' ) );
}

/**
* Whether a stored value is a Slack member ID rather than a handle:
* uppercase letters and at least one digit, starting with `U`, or `W` on
* Enterprise Grid, as Slack's "Copy member ID" gives them. The digit keeps
* an all-caps handle such as `WALTERWHITE` a handle.
*
* @param string $value Normalized handle or member ID.
* @return bool
*/
public static function is_member_id( string $value ): bool {
return 1 === preg_match( '/^[UW](?=[A-Z]*[0-9])[A-Z0-9]{8,}$/', $value );
}

/**
* IDs of the users whose stored value matches, lowest first. A member ID
* matches stored member IDs exactly; a handle matches stored handles,
* ignoring case as the database collation does.
*
* @param string $value Normalized handle or member ID.
* @param bool $member_id Whether `$value` is a member ID.
* @return int[]
*/
private static function find_user_ids( string $value, bool $member_id ): array {
$user_ids = get_users(
[
'meta_key' => self::META_SLACK_HANDLE, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'fields' => 'ID',
'orderby' => 'ID',
'order' => 'ASC',
]
);

return array_values(
array_filter(
array_map( 'intval', $user_ids ),
static function ( int $user_id ) use ( $value, $member_id ): bool {
$stored = (string) get_user_meta( $user_id, self::META_SLACK_HANDLE, true );
return $member_id ? $stored === $value : ! self::is_member_id( $stored );
}
)
);
}

/**
* Render the Rolling Coverage section on the profile and user edit
* screens, for users who can write entries and so can be credited.
*
* @param \WP_User $user The user being edited.
*/
public static function render_profile_section( \WP_User $user ): void {
if ( ! user_can( $user, 'edit_posts' ) ) {
return;
}

$handle = (string) get_user_meta( $user->ID, self::META_SLACK_HANDLE, true );
$description_id = self::FIELD_SLACK_HANDLE . '_description';
?>
<h2><?php esc_html_e( 'Rolling Coverage', 'newspack-rolling-coverage' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><label for="<?php echo esc_attr( self::FIELD_SLACK_HANDLE ); ?>"><?php esc_html_e( 'Slack handle', 'newspack-rolling-coverage' ); ?></label></th>
<td>
<input type="text" name="<?php echo esc_attr( self::FIELD_SLACK_HANDLE ); ?>" id="<?php echo esc_attr( self::FIELD_SLACK_HANDLE ); ?>" value="<?php echo esc_attr( $handle ); ?>" class="regular-text" aria-describedby="<?php echo esc_attr( $description_id ); ?>" />
<div id="<?php echo esc_attr( $description_id ); ?>">
<p class="description"><?php esc_html_e( 'A Slack handle or member ID. Rolling coverage entries posted from Slack by that person are credited to this user. A member ID is uppercase, starts with U or W and includes digits, such as U012AB3CDE; anything else is read as a handle.', 'newspack-rolling-coverage' ); ?></p>
<p class="description"><?php esc_html_e( 'A member ID is the safer choice: it never changes, and nobody can match it by changing their name in Slack, as they can with a handle. To find a member ID in Slack, click the profile picture, choose Profile, then open the More (⋮) menu and choose Copy member ID.', 'newspack-rolling-coverage' ); ?></p>
</div>
</td>
</tr>
</table>
<?php
}

/**
* Save the Slack handle or member ID from the profile and user edit
* screens. A value already mapped to another user who can be credited is
* refused, so a Slack message has one person it could go to. A user who
* later regains `edit_posts` can bring back a value someone else has
* since saved; the lower user ID is then credited.
*
* @param int $user_id ID of the user being saved.
*/
public static function save_profile_section( int $user_id ): void {
if ( ! current_user_can( 'edit_user', $user_id ) || ! user_can( $user_id, 'edit_posts' ) ) {
return;
}

// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Only checks the field was submitted; the nonce is verified next.
if ( ! isset( $_POST[ self::FIELD_SLACK_HANDLE ] ) ) {
return;
}

check_admin_referer( 'update-user_' . $user_id );

$handle = self::normalize_handle( sanitize_text_field( wp_unslash( $_POST[ self::FIELD_SLACK_HANDLE ] ) ) );

if ( '' === $handle ) {
delete_user_meta( $user_id, self::META_SLACK_HANDLE );
return;
}

$holders = array_filter(
self::find_user_ids( $handle, self::is_member_id( $handle ) ),
fn( $holder_id ) => $holder_id !== $user_id && user_can( $holder_id, 'edit_posts' )
);
Comment thread
leogermani marked this conversation as resolved.

if ( ! empty( $holders ) ) {
add_action(
'user_profile_update_errors',
static function ( \WP_Error $errors ) use ( $handle ) {
$errors->add(
'rolling_coverage_slack_handle_taken',
sprintf(
/* translators: %s: Slack handle or member ID. */
__( '<strong>Error:</strong> The Slack handle or member ID %s is already assigned to another user.', 'newspack-rolling-coverage' ),
'<code>' . esc_html( $handle ) . '</code>'
)
);
}
);
return;
}

// update_user_meta() unslashes what it stores; slash the value so a
// backslash in it is stored as checked above.
update_user_meta( $user_id, self::META_SLACK_HANDLE, wp_slash( $handle ) );
}
}
11 changes: 7 additions & 4 deletions includes/slack/class-slack-webhook-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -1552,8 +1552,10 @@ static function ( string $mentioned_user_id ) use ( $api_client, $can_call_api,
// 2. Content processing.
$content = $content_processor->process( $text, is_array( $event['blocks'] ?? null ) ? $event['blocks'] : [] );

// 3. Bot user resolution.
$bot_user_id = Slack_Author_Resolver::get_slack_bot_user_id();
// 3. The entry's author: the WordPress user mapped to the message
// author's Slack member ID or handle, or the bot user.
$author = Slack_Author_Resolver::resolve_author( $user_id, $user_info );
$author_id = $author['user_id'];

// 4. Build the normalized payload.
$source_payload = new Source_Event_Payload(
Expand All @@ -1580,13 +1582,13 @@ static function ( string $mentioned_user_id ) use ( $api_client, $can_call_api,
// 6. Call the generic ingestion service. Uploaded images are imported
// from inside it, once the message is known not to be a redelivery.
$files = is_array( $event['files'] ?? null ) ? $event['files'] : [];
$media_importer = new Slack_Media_Importer( $api_client, $bot_user_id );
$media_importer = new Slack_Media_Importer( $api_client, $author_id );

$post_id = Entry_Ingestion_Service::ingest(
$source_payload,
$term_id,
$auto_publish,
$bot_user_id,
$author_id,
$provenance_meta,
static fn( callable $keep_lock ): string => $media_importer->import( $files, $keep_lock )
);
Expand Down Expand Up @@ -1628,6 +1630,7 @@ static function ( string $mentioned_user_id ) use ( $api_client, $can_call_api,
'post_id' => (int) $post_id,
'channel' => $channel_id,
'status' => $auto_publish ? 'publish' : 'draft',
'author' => $author['matched_by'],
]
);
}
Expand Down
8 changes: 4 additions & 4 deletions includes/sources/class-entry-ingestion-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class Entry_Ingestion_Service {
* @param Source_Event_Payload $payload Normalized event.
* @param int $term_id Resolved rolling coverage term id.
* @param bool $auto_publish Whether to insert as 'publish' or 'draft'.
* @param int $bot_user_id WP user id to assign as post_author.
* @param int $author_id WP user id to assign as post_author.
* @param array<string, mixed> $provenance_meta Platform-specific meta keyed by meta_key.
* @param callable|null $render_media Returns block markup for the event's
* media, added after the content. Only
Expand All @@ -64,7 +64,7 @@ public static function ingest(
Source_Event_Payload $payload,
int $term_id,
bool $auto_publish,
int $bot_user_id,
int $author_id,
array $provenance_meta,
?callable $render_media = null
) {
Expand Down Expand Up @@ -93,7 +93,7 @@ public static function ingest(
return 0;
}

if ( $bot_user_id <= 0 ) {
if ( $author_id <= 0 ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'Source ingestion: bot user unavailable, skipping entry.' );
return 0;
Expand Down Expand Up @@ -121,7 +121,7 @@ public static function ingest(
'post_type' => Post_Type::CPT_SLUG,
'post_title' => '',
'post_content' => wp_slash( $content ),
'post_author' => $bot_user_id,
'post_author' => $author_id,
'post_status' => $auto_publish ? 'publish' : 'draft',
];

Expand Down
20 changes: 20 additions & 0 deletions src/admin/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ The entries list has no Source column. The source shows as a marker before the t

The `source` field stays defined, with no `render` and `enableHiding: false`. DataViews builds filters from every field, whether or not it's shown, so Source is offered under **Add filter** but never as a column.

## Slack authors

An entry ingested from Slack is credited to the WordPress user whose Slack member ID or handle matches the message's author, and to the Slack bot user (`Slack_Config::get_or_create_bot_user_id()`) when nobody's does. The value is set in the **Slack handle** field of the **Rolling Coverage** section on the profile and user edit screens, and stored in the `rolling_coverage_slack_handle` user meta (`Slack_Author_Resolver::META_SLACK_HANDLE`). The section shows only for users who can `edit_posts`, since nobody else can be credited.

- A value that starts with `U` or `W` and is uppercase letters with at least one digit (`Slack_Author_Resolver::is_member_id()`) is a member ID; anything else is a handle. Values are stored trimmed and without a leading `@`.
- Saving a value that another user who can `edit_posts` already has is refused with an error on the profile screen, so a message has one person it could be credited to. Users without `edit_posts` are ignored, so a value left on a demoted user's profile can come back alongside someone else's when that user regains `edit_posts`; the lower user ID is then credited.
- A member ID typed in lowercase is a handle, so it never matches its owner's messages. Slack's **Copy member ID** gives it in uppercase.
- At ingest, `Slack_Author_Resolver::resolve_author()` compares the message's member ID with stored member IDs, exactly. It then compares the author's Slack display name, full name and username, in that order, with stored handles only, ignoring case as the database collation does. Names are free text their owner can change, so a name shaped like a member ID never reaches a member-ID mapping. Only users who can `edit_posts` are credited.
- Names need Slack's `users.info` lookup (1s budget on the webhook). When it fails, only a member ID can match.
- Images uploaded with the message are owned by the same user as the entry.
- The ingest log's `author` field records what matched (`member_id`, `display_name`, `real_name`, `name` or `bot`), so entries credited through a name can be found if one turns out to be wrong.

Trust model:

- Anyone in the Slack workspace can change their name to match someone's handle. A handle therefore tells who posted only as far as the channel's members can be trusted to use their own names. Slack never lets anyone change a member ID, which is why the field recommends one.
- The field is self-service: anyone who can `edit_posts` can enter any value on their own profile, including a colleague's member ID, and nothing checks it against Slack. A value is first come, first served, and an administrator settles a dispute by clearing it on the other profile.
- On multisite, the meta is shared across the network, but the duplicate check only sees users of the current site.

Tests: `tests/test-slack-author-resolver.php`, and the end-to-end cases in `tests/test-slack-webhook.php`.

## Adding a source

The taxonomy already anticipates other chat platforms (Beeper, WhatsApp, Telegram). A new source needs each of these:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function IngestionSettingsTab( {
heading={ 2 }
title={ __( 'Bot User', 'newspack-rolling-coverage' ) }
description={ __(
'This WordPress user is created automatically and is the author of every entry ingested from Slack. Change its display name or avatar from its WordPress profile.',
"This WordPress user is created automatically. Entries ingested from Slack are credited to it, unless the person who posted them has added their Slack handle or member ID to their WordPress profile. Change the bot user's display name or avatar from its WordPress profile.",
'newspack-rolling-coverage'
) }
/>
Expand Down
Loading
Loading