settings: Add two realm settings to restrict direct messages.

Fixes #24467.
This commit is contained in:
Vector73 2024-07-08 22:30:08 +05:30 committed by Tim Abbott
parent 318d3e3cca
commit 6098c2cebe
45 changed files with 975 additions and 209 deletions

View File

@ -20,6 +20,18 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 9.0 ## Changes in Zulip 9.0
**Feature level 270**
* `PATCH /realm`, [`POST /register`](/api/register-queue),
[`GET /events`](/api/get-events): Added two new realm settings,
`direct_message_initiator_group`, which is a
[group-setting value](/api/group-setting-values) describing the
set of users with permission to initiate direct message thread, and
`direct_message_permission_group`, which is a
[group-setting value](/api/group-setting-values) describing the
set of users of which at least one member must be included as sender
or recipient in all personal and group direct messages.
**Feature level 269** **Feature level 269**
* [`POST /register`](/api/register-queue), [`PATCH * [`POST /register`](/api/register-queue), [`PATCH

View File

@ -33,7 +33,9 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 269
API_FEATURE_LEVEL = 270 # Last bumped for direct_message_permission_group
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@ -142,6 +142,8 @@ export function build_page() {
language_list, language_list,
realm_default_language_name: get_language_name(realm.realm_default_language), realm_default_language_name: get_language_name(realm.realm_default_language),
realm_default_language_code: realm.realm_default_language, realm_default_language_code: realm.realm_default_language,
realm_direct_message_initiator_group_id: realm.realm_direct_message_initiator_group,
realm_direct_message_permission_group_id: realm.realm_direct_message_permission_group,
realm_waiting_period_threshold: realm.realm_waiting_period_threshold, realm_waiting_period_threshold: realm.realm_waiting_period_threshold,
realm_new_stream_announcements_stream_id: realm.realm_new_stream_announcements_stream_id, realm_new_stream_announcements_stream_id: realm.realm_new_stream_announcements_stream_id,
realm_signup_announcements_stream_id: realm.realm_signup_announcements_stream_id, realm_signup_announcements_stream_id: realm.realm_signup_announcements_stream_id,
@ -276,6 +278,10 @@ export function build_page() {
tippy.default($("#realm_can_access_all_users_group_widget_container")[0], opts); tippy.default($("#realm_can_access_all_users_group_widget_container")[0], opts);
} }
settings_org.check_disable_direct_message_initiator_group_dropdown(
realm.realm_direct_message_permission_group,
);
} }
export function launch(section, user_settings_tab) { export function launch(section, user_settings_tab) {

View File

@ -15,6 +15,7 @@ import * as compose_validate from "./compose_validate";
import * as drafts from "./drafts"; import * as drafts from "./drafts";
import * as message_lists from "./message_lists"; import * as message_lists from "./message_lists";
import type {Message} from "./message_store"; import type {Message} from "./message_store";
import * as message_util from "./message_util";
import * as message_viewport from "./message_viewport"; import * as message_viewport from "./message_viewport";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
@ -22,9 +23,7 @@ import * as people from "./people";
import * as popovers from "./popovers"; import * as popovers from "./popovers";
import * as reload_state from "./reload_state"; import * as reload_state from "./reload_state";
import * as resize from "./resize"; import * as resize from "./resize";
import * as settings_config from "./settings_config";
import * as spectators from "./spectators"; import * as spectators from "./spectators";
import {realm} from "./state_data";
import * as stream_data from "./stream_data"; import * as stream_data from "./stream_data";
// Opts sent to `compose_actions.start`. // Opts sent to `compose_actions.start`.
@ -530,26 +529,21 @@ export function on_narrow(opts: NarrowActivateOpts): void {
} }
return; return;
} }
// Do not open compose box if organization has disabled sending // Do not open compose box if sender is not allowed to send direct message.
// direct messages and recipient is not a bot. const recipient_ids_string = people.emails_strings_to_user_ids_string(
opts.private_message_recipient,
);
if ( if (
realm.realm_private_message_policy === recipient_ids_string &&
settings_config.private_message_policy_values.disabled.code && !message_util.user_can_send_direct_message(recipient_ids_string)
opts.private_message_recipient
) { ) {
const emails = opts.private_message_recipient.split(","); // If we are navigating between direct message conversation,
if ( // we want the compose box to close for non-bot users.
emails.length !== 1 || if (compose_state.composing()) {
emails[0] === undefined || cancel();
!people.get_by_email(emails[0])!.is_bot
) {
// If we are navigating between direct message conversations,
// we want the compose box to close for non-bot users.
if (compose_state.composing()) {
cancel();
}
return;
} }
return;
} }
// Open the compose box, passing the option to skip attempting // Open the compose box, passing the option to skip attempting

View File

@ -1,5 +1,6 @@
import $ from "jquery"; import $ from "jquery";
import render_cannot_send_direct_message_error from "../templates/compose_banner/cannot_send_direct_message_error.hbs";
import render_compose_banner from "../templates/compose_banner/compose_banner.hbs"; import render_compose_banner from "../templates/compose_banner/compose_banner.hbs";
import render_stream_does_not_exist_error from "../templates/compose_banner/stream_does_not_exist_error.hbs"; import render_stream_does_not_exist_error from "../templates/compose_banner/stream_does_not_exist_error.hbs";
@ -48,7 +49,7 @@ export const CLASSNAMES = {
stream_does_not_exist: "stream_does_not_exist", stream_does_not_exist: "stream_does_not_exist",
missing_stream: "missing_stream", missing_stream: "missing_stream",
no_post_permissions: "no_post_permissions", no_post_permissions: "no_post_permissions",
private_messages_disabled: "private_messages_disabled", cannot_send_direct_message: "cannot_send_direct_message",
missing_private_message_recipient: "missing_private_message_recipient", missing_private_message_recipient: "missing_private_message_recipient",
invalid_recipient: "invalid_recipient", invalid_recipient: "invalid_recipient",
invalid_recipients: "invalid_recipients", invalid_recipients: "invalid_recipients",
@ -183,6 +184,21 @@ export function show_error_message(
} }
} }
export function cannot_send_direct_message_error(error_message: string): void {
// Remove any existing banners with this warning.
$(`#compose_banners .${CSS.escape(CLASSNAMES.cannot_send_direct_message)}`).remove();
const new_row_html = render_cannot_send_direct_message_error({
banner_type: ERROR,
error_message,
classname: CLASSNAMES.cannot_send_direct_message,
});
append_compose_banner_to_banner_list($(new_row_html), $("#compose_banners"));
hide_compose_spinner();
$("#private_message_recipient").trigger("focus").trigger("select");
}
export function show_stream_does_not_exist_error(stream_name: string): void { export function show_stream_does_not_exist_error(stream_name: string): void {
// Remove any existing banners with this warning. // Remove any existing banners with this warning.
$(`#compose_banners .${CSS.escape(CLASSNAMES.stream_does_not_exist)}`).remove(); $(`#compose_banners .${CSS.escape(CLASSNAMES.stream_does_not_exist)}`).remove();

View File

@ -4,6 +4,7 @@ import * as compose_actions from "./compose_actions";
import {$t} from "./i18n"; import {$t} from "./i18n";
import * as message_lists from "./message_lists"; import * as message_lists from "./message_lists";
import * as message_store from "./message_store"; import * as message_store from "./message_store";
import * as message_util from "./message_util";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import * as people from "./people"; import * as people from "./people";
import * as stream_data from "./stream_data"; import * as stream_data from "./stream_data";
@ -132,7 +133,7 @@ export function update_buttons_for_private(): void {
let disable_reply; let disable_reply;
if (!pm_ids_string || people.user_can_direct_message(pm_ids_string)) { if (!pm_ids_string || message_util.user_can_send_direct_message(pm_ids_string)) {
disable_reply = false; disable_reply = false;
} else { } else {
// disable the [Message X] button when in a private narrow // disable the [Message X] button when in a private narrow

View File

@ -19,12 +19,11 @@ import * as dropdown_widget from "./dropdown_widget";
import type {Option} from "./dropdown_widget"; import type {Option} from "./dropdown_widget";
import {$t} from "./i18n"; import {$t} from "./i18n";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import * as people from "./people";
import * as settings_config from "./settings_config";
import {realm} from "./state_data"; import {realm} from "./state_data";
import * as stream_data from "./stream_data"; import * as stream_data from "./stream_data";
import * as sub_store from "./sub_store"; import * as sub_store from "./sub_store";
import * as ui_util from "./ui_util"; import * as ui_util from "./ui_util";
import * as user_groups from "./user_groups";
import * as util from "./util"; import * as util from "./util";
type MessageType = "stream" | "private"; type MessageType = "stream" | "private";
@ -116,12 +115,7 @@ export function update_on_recipient_change(): void {
export function get_posting_policy_error_message(): string { export function get_posting_policy_error_message(): string {
if (compose_state.selected_recipient_id === "direct") { if (compose_state.selected_recipient_id === "direct") {
const recipients = compose_pm_pill.get_user_ids_string(); const recipients = compose_pm_pill.get_user_ids_string();
if (!people.user_can_direct_message(recipients)) { return compose_validate.check_dm_permissions_and_get_error_string(recipients);
return $t({
defaultMessage: "You are not allowed to send direct messages in this organization.",
});
}
return "";
} }
if (!isNumber(compose_state.selected_recipient_id)) { if (!isNumber(compose_state.selected_recipient_id)) {
@ -146,11 +140,13 @@ export function check_posting_policy_for_compose_box(): void {
} }
let banner_classname = compose_banner.CLASSNAMES.no_post_permissions; let banner_classname = compose_banner.CLASSNAMES.no_post_permissions;
if (compose_state.selected_recipient_id === "direct") {
banner_classname = compose_banner.CLASSNAMES.private_messages_disabled;
}
compose_validate.set_recipient_disallowed(true); compose_validate.set_recipient_disallowed(true);
compose_banner.show_error_message(banner_text, banner_classname, $("#compose_banners")); if (compose_state.selected_recipient_id === "direct") {
banner_classname = compose_banner.CLASSNAMES.cannot_send_direct_message;
compose_banner.cannot_send_direct_message_error(banner_text);
} else {
compose_banner.show_error_message(banner_text, banner_classname, $("#compose_banners"));
}
} }
function switch_message_type(message_type: MessageType): void { function switch_message_type(message_type: MessageType): void {
@ -263,10 +259,8 @@ function get_options_for_recipient_widget(): Option[] {
name: $t({defaultMessage: "Direct message"}), name: $t({defaultMessage: "Direct message"}),
}; };
if ( const {name} = user_groups.get_user_group_from_id(realm.realm_direct_message_permission_group);
realm.realm_private_message_policy === if (name !== "role:nobody") {
settings_config.private_message_policy_values.by_anyone.code
) {
options.unshift(direct_messages_option); options.unshift(direct_messages_option);
} else { } else {
options.push(direct_messages_option); options.push(direct_messages_option);

View File

@ -10,6 +10,7 @@ import * as compose_recipient from "./compose_recipient";
import * as compose_state from "./compose_state"; import * as compose_state from "./compose_state";
import * as compose_validate from "./compose_validate"; import * as compose_validate from "./compose_validate";
import {$t} from "./i18n"; import {$t} from "./i18n";
import {pick_empty_narrow_banner} from "./narrow_banner";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import * as popover_menus from "./popover_menus"; import * as popover_menus from "./popover_menus";
import {EXTRA_LONG_HOVER_DELAY, INSTANT_HOVER_DELAY, LONG_HOVER_DELAY} from "./tippyjs"; import {EXTRA_LONG_HOVER_DELAY, INSTANT_HOVER_DELAY, LONG_HOVER_DELAY} from "./tippyjs";
@ -47,11 +48,7 @@ export function initialize(): void {
const button_type = $elem.attr("data-reply-button-type"); const button_type = $elem.attr("data-reply-button-type");
switch (button_type) { switch (button_type) {
case "direct_disabled": { case "direct_disabled": {
instance.setContent( instance.setContent(pick_empty_narrow_banner().title);
parse_html(
$("#compose_reply_direct_disabled_button_tooltip_template").html(),
),
);
return; return;
} }
case "selected_message": { case "selected_message": {

View File

@ -14,6 +14,7 @@ import * as compose_state from "./compose_state";
import * as compose_ui from "./compose_ui"; import * as compose_ui from "./compose_ui";
import {$t} from "./i18n"; import {$t} from "./i18n";
import * as message_store from "./message_store"; import * as message_store from "./message_store";
import * as message_util from "./message_util";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import * as peer_data from "./peer_data"; import * as peer_data from "./peer_data";
import * as people from "./people"; import * as people from "./people";
@ -26,6 +27,7 @@ import * as stream_data from "./stream_data";
import * as sub_store from "./sub_store"; import * as sub_store from "./sub_store";
import type {StreamSubscription} from "./sub_store"; import type {StreamSubscription} from "./sub_store";
import type {UserOrMention} from "./typeahead_helper"; import type {UserOrMention} from "./typeahead_helper";
import * as user_groups from "./user_groups";
import * as util from "./util"; import * as util from "./util";
let user_acknowledged_stream_wildcard = false; let user_acknowledged_stream_wildcard = false;
@ -110,6 +112,32 @@ export function needs_subscribe_warning(user_id: number, stream_id: number): boo
return true; return true;
} }
export function check_dm_permissions_and_get_error_string(user_ids_string: string): string {
if (!people.user_can_direct_message(user_ids_string)) {
const {name} = user_groups.get_user_group_from_id(
realm.realm_direct_message_permission_group,
);
if (name === "role:nobody") {
return $t({
defaultMessage: "Direct messages are disabled in this organization.",
});
}
return $t({
defaultMessage: "This conversation does not include any users who can authorize it.",
});
}
if (
message_util.get_direct_message_permission_hints(user_ids_string)
.is_known_empty_conversation &&
!people.user_can_initiate_direct_message_thread(user_ids_string)
) {
return $t({
defaultMessage: "You are not allowed to start direct message conversations.",
});
}
return "";
}
function get_stream_id_for_textarea($textarea: JQuery<HTMLTextAreaElement>): number | undefined { function get_stream_id_for_textarea($textarea: JQuery<HTMLTextAreaElement>): number | undefined {
// Returns the stream ID, if any, associated with the textarea: // Returns the stream ID, if any, associated with the textarea:
// The recipient of a message being edited, or the target // The recipient of a message being edited, or the target
@ -619,20 +647,9 @@ function validate_stream_message(scheduling_message: boolean): boolean {
// for now) // for now)
function validate_private_message(): boolean { function validate_private_message(): boolean {
const user_ids = compose_pm_pill.get_user_ids(); const user_ids = compose_pm_pill.get_user_ids();
const user_ids_string = util.sorted_ids(user_ids).join(",");
const $banner_container = $("#compose_banners"); const $banner_container = $("#compose_banners");
const user_ids_string = user_ids.join(",");
if (!people.user_can_direct_message(user_ids_string)) {
compose_banner.show_error_message(
$t({defaultMessage: "Direct messages are disabled in this organization."}),
compose_banner.CLASSNAMES.private_messages_disabled,
$banner_container,
$("#private_message_recipient"),
);
return false;
}
if (compose_state.private_message_recipient().length === 0) { if (compose_state.private_message_recipient().length === 0) {
compose_banner.show_error_message( compose_banner.show_error_message(
$t({defaultMessage: "Please specify at least one valid recipient."}), $t({defaultMessage: "Please specify at least one valid recipient."}),
@ -646,6 +663,12 @@ function validate_private_message(): boolean {
return true; return true;
} }
const direct_message_error_string = check_dm_permissions_and_get_error_string(user_ids_string);
if (direct_message_error_string) {
compose_banner.cannot_send_direct_message_error(direct_message_error_string);
return false;
}
const invalid_recipients = get_invalid_recipient_emails(); const invalid_recipients = get_invalid_recipient_emails();
let context = {}; let context = {};

View File

@ -10,6 +10,7 @@ import * as markdown from "./markdown";
import * as message_lists from "./message_lists"; import * as message_lists from "./message_lists";
import * as message_live_update from "./message_live_update"; import * as message_live_update from "./message_live_update";
import * as message_store from "./message_store"; import * as message_store from "./message_store";
import * as message_util from "./message_util";
import * as people from "./people"; import * as people from "./people";
import * as pm_list from "./pm_list"; import * as pm_list from "./pm_list";
import * as recent_view_data from "./recent_view_data"; import * as recent_view_data from "./recent_view_data";
@ -224,6 +225,14 @@ export function try_deliver_locally(message_request, insert_new_messages) {
// view; this is useful to ensure it will be visible in other // view; this is useful to ensure it will be visible in other
// views that we might navigate to before we get a response from // views that we might navigate to before we get a response from
// the server. // the server.
if (
message_request.to_user_ids &&
!people.user_can_initiate_direct_message_thread(message_request.to_user_ids) &&
!message_util.get_direct_message_permission_hints(message_request.to_user_ids)
.is_local_echo_safe
) {
return undefined;
}
if (markdown.contains_backend_only_syntax(message_request.content)) { if (markdown.contains_backend_only_syntax(message_request.content)) {
return undefined; return undefined;
} }

View File

@ -3,6 +3,8 @@ import $ from "jquery";
import {all_messages_data} from "./all_messages_data"; import {all_messages_data} from "./all_messages_data";
import * as blueslip from "./blueslip"; import * as blueslip from "./blueslip";
import * as channel from "./channel"; import * as channel from "./channel";
import * as compose_closed_ui from "./compose_closed_ui";
import * as compose_recipient from "./compose_recipient";
import * as direct_message_group_data from "./direct_message_group_data"; import * as direct_message_group_data from "./direct_message_group_data";
import * as message_feed_loading from "./message_feed_loading"; import * as message_feed_loading from "./message_feed_loading";
import * as message_feed_top_notices from "./message_feed_top_notices"; import * as message_feed_top_notices from "./message_feed_top_notices";
@ -106,6 +108,8 @@ function process_result(data, opts) {
// Even after loading more messages, we have // Even after loading more messages, we have
// no messages to display in this narrow. // no messages to display in this narrow.
narrow_banner.show_empty_narrow_message(); narrow_banner.show_empty_narrow_message();
compose_closed_ui.update_buttons_for_private();
compose_recipient.check_posting_policy_for_compose_box();
} }
if (opts.num_before > 0 && !has_found_oldest) { if (opts.num_before > 0 && !has_found_oldest) {

View File

@ -1,11 +1,21 @@
import assert from "minimalistic-assert";
import {all_messages_data} from "./all_messages_data"; import {all_messages_data} from "./all_messages_data";
import type {MessageListData} from "./message_list_data"; import type {MessageListData} from "./message_list_data";
import type {MessageList, RenderInfo} from "./message_lists"; import {type MessageList, type RenderInfo} from "./message_lists";
import * as message_lists from "./message_lists";
import * as message_store from "./message_store"; import * as message_store from "./message_store";
import type {Message} from "./message_store"; import type {Message} from "./message_store";
import * as people from "./people";
import * as pm_conversations from "./pm_conversations";
import * as unread from "./unread"; import * as unread from "./unread";
import * as unread_ui from "./unread_ui"; import * as unread_ui from "./unread_ui";
type DirectMessagePermissionHints = {
is_known_empty_conversation: boolean;
is_local_echo_safe: boolean;
};
export function do_unread_count_updates(messages: Message[], expect_no_new_unreads = false): void { export function do_unread_count_updates(messages: Message[], expect_no_new_unreads = false): void {
const any_new_unreads = unread.process_loaded_messages(messages, expect_no_new_unreads); const any_new_unreads = unread.process_loaded_messages(messages, expect_no_new_unreads);
@ -112,3 +122,45 @@ export function get_topics_for_message_ids(message_ids: number[]): Map<string, [
} }
return topics; return topics;
} }
export function get_direct_message_permission_hints(
recipient_ids_string: string,
): DirectMessagePermissionHints {
// Check if there are any previous messages in the DM conversation.
const have_conversation_in_cache =
pm_conversations.recent.has_conversation(recipient_ids_string);
if (have_conversation_in_cache) {
return {is_known_empty_conversation: false, is_local_echo_safe: true};
}
// If not, we need to check if the current filter matches the DM view we
// are composing to.
const dm_conversation = message_lists.current?.data?.filter.operands("dm")[0];
if (dm_conversation) {
const current_user_ids_string = people.emails_strings_to_user_ids_string(dm_conversation);
assert(current_user_ids_string !== undefined);
// If it matches and the messages for the current filter are fetched,
// then there are certainly no messages in the conversation.
if (
people.pm_lookup_key(recipient_ids_string) ===
people.pm_lookup_key(current_user_ids_string) &&
message_lists.current?.data?.fetch_status.has_found_newest()
) {
return {is_known_empty_conversation: true, is_local_echo_safe: true};
}
}
// If it does not match, then there can be messages in the DM conversation
// which are not fetched locally and hence we disable local echo for clean
// error handling in case there are no messages in the conversation and
// user is not allowed to initiate DM conversations.
return {is_known_empty_conversation: false, is_local_echo_safe: false};
}
export function user_can_send_direct_message(user_ids_string: string): boolean {
return (
(!get_direct_message_permission_hints(user_ids_string).is_known_empty_conversation ||
people.user_can_initiate_direct_message_thread(user_ids_string)) &&
people.user_can_direct_message(user_ids_string)
);
}

View File

@ -2,16 +2,17 @@ import $ from "jquery";
import _ from "lodash"; import _ from "lodash";
import assert from "minimalistic-assert"; import assert from "minimalistic-assert";
import * as compose_validate from "./compose_validate";
import {$t, $t_html} from "./i18n"; import {$t, $t_html} from "./i18n";
import type {NarrowBannerData, SearchData} from "./narrow_error"; import type {NarrowBannerData, SearchData} from "./narrow_error";
import {narrow_error} from "./narrow_error"; import {narrow_error} from "./narrow_error";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
import * as people from "./people"; import * as people from "./people";
import * as settings_config from "./settings_config";
import * as spectators from "./spectators"; import * as spectators from "./spectators";
import {realm} from "./state_data"; import {realm} from "./state_data";
import * as stream_data from "./stream_data"; import * as stream_data from "./stream_data";
import * as util from "./util";
const SPECTATOR_STREAM_NARROW_BANNER = { const SPECTATOR_STREAM_NARROW_BANNER = {
title: "", title: "",
@ -104,7 +105,7 @@ function retrieve_search_query_data(): SearchData {
return search_string_result; return search_string_result;
} }
function pick_empty_narrow_banner(): NarrowBannerData { export function pick_empty_narrow_banner(): NarrowBannerData {
const default_banner = { const default_banner = {
title: $t({defaultMessage: "There are no messages here."}), title: $t({defaultMessage: "There are no messages here."}),
// Spectators cannot start a conversation. // Spectators cannot start a conversation.
@ -230,17 +231,6 @@ function pick_empty_narrow_banner(): NarrowBannerData {
return MENTIONS_VIEW_EMPTY_BANNER; return MENTIONS_VIEW_EMPTY_BANNER;
case "dm": case "dm":
// You have no direct messages. // You have no direct messages.
if (
realm.realm_private_message_policy ===
settings_config.private_message_policy_values.disabled.code
) {
return {
title: $t({
defaultMessage:
"You are not allowed to send direct messages in this organization.",
}),
};
}
return { return {
title: $t({defaultMessage: "You have no direct messages yet!"}), title: $t({defaultMessage: "You have no direct messages yet!"}),
html: $t_html( html: $t_html(
@ -324,16 +314,21 @@ function pick_empty_narrow_banner(): NarrowBannerData {
} }
const user_ids = people.emails_strings_to_user_ids_array(first_operand); const user_ids = people.emails_strings_to_user_ids_array(first_operand);
assert(user_ids?.[0] !== undefined); assert(user_ids?.[0] !== undefined);
if ( const user_ids_string = util.sorted_ids(user_ids).join(",");
realm.realm_private_message_policy === const direct_message_error_string =
settings_config.private_message_policy_values.disabled.code && compose_validate.check_dm_permissions_and_get_error_string(user_ids_string);
(user_ids.length !== 1 || !people.get_by_user_id(user_ids[0]).is_bot) if (direct_message_error_string) {
) {
return { return {
title: $t({ title: direct_message_error_string,
defaultMessage: html: $t_html(
"You are not allowed to send direct messages in this organization.", {
}), defaultMessage: "<z-link>Learn more.</z-link>",
},
{
"z-link": (content_html) =>
`<a target="_blank" rel="noopener noreferrer" href="/help/restrict-direct-messages">${content_html.join("")}</a>`,
},
),
}; };
} }
if (!first_operand.includes(",")) { if (!first_operand.includes(",")) {
@ -409,16 +404,21 @@ function pick_empty_narrow_banner(): NarrowBannerData {
title: $t({defaultMessage: "This user does not exist!"}), title: $t({defaultMessage: "This user does not exist!"}),
}; };
} }
if ( const person_id_string = person_in_dms.user_id.toString();
realm.realm_private_message_policy === const direct_message_error_string =
settings_config.private_message_policy_values.disabled.code && compose_validate.check_dm_permissions_and_get_error_string(person_id_string);
!person_in_dms.is_bot if (direct_message_error_string) {
) {
return { return {
title: $t({ title: direct_message_error_string,
defaultMessage: html: $t_html(
"You are not allowed to send direct messages in this organization.", {
}), defaultMessage: "<z-link>Learn more.</z-link>",
},
{
"z-link": (content_html) =>
`<a target="_blank" rel="noopener noreferrer" href="/help/restrict-direct-messages">${content_html.join("")}</a>`,
},
),
}; };
} }
if (people.is_current_user(first_operand)) { if (people.is_current_user(first_operand)) {

View File

@ -22,6 +22,7 @@ import type {
} from "./state_data"; } from "./state_data";
import {current_user, realm} from "./state_data"; import {current_user, realm} from "./state_data";
import * as timerender from "./timerender"; import * as timerender from "./timerender";
import {is_user_in_group} from "./user_groups";
import {user_settings} from "./user_settings"; import {user_settings} from "./user_settings";
import * as util from "./util"; import * as util from "./util";
@ -760,28 +761,38 @@ export function should_add_guest_user_indicator(user_id: number): boolean {
return user.is_guest; return user.is_guest;
} }
export function user_can_direct_message(recipient_ids_string: string): boolean { export function user_can_initiate_direct_message_thread(recipient_ids_string: string): boolean {
// Common function for checking if a user can send a direct const direct_message_initiator_group_id = realm.realm_direct_message_initiator_group;
// message to the target user (or group of users) represented by a
// user ids string.
// Regardless of policy, we allow sending direct messages to bots and to self.
const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string); const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string);
if ( if (is_user_in_group(direct_message_initiator_group_id, my_user_id)) {
recipient_ids.length === 1 && return true;
recipient_ids[0] !== undefined && }
(is_valid_bot_user(recipient_ids[0]) || is_my_user_id(recipient_ids[0])) for (const recipient of recipient_ids) {
) { if (!is_valid_bot_user(recipient) && recipient !== my_user_id) {
return false;
}
}
return true;
}
export function user_can_direct_message(recipient_ids_string: string): boolean {
const direct_message_permission_group_id = realm.realm_direct_message_permission_group;
const recipient_ids = user_ids_string_to_ids_array(recipient_ids_string);
if (is_user_in_group(direct_message_permission_group_id, my_user_id)) {
return true; return true;
} }
if ( let other_human_recipients_exist = false;
realm.realm_private_message_policy === for (const recipient_id of recipient_ids) {
settings_config.private_message_policy_values.disabled.code if (is_valid_bot_user(recipient_id) || recipient_id === my_user_id) {
) { continue;
return false; }
if (is_user_in_group(direct_message_permission_group_id, recipient_id)) {
return true;
}
other_human_recipients_exist = true;
} }
return true; return !other_human_recipients_exist;
} }
function gravatar_url_for_email(email: string): string { function gravatar_url_for_email(email: string): string {

View File

@ -12,6 +12,7 @@ import * as browser_history from "./browser_history";
import {buddy_list} from "./buddy_list"; import {buddy_list} from "./buddy_list";
import * as compose_call from "./compose_call"; import * as compose_call from "./compose_call";
import * as compose_call_ui from "./compose_call_ui"; import * as compose_call_ui from "./compose_call_ui";
import * as compose_closed_ui from "./compose_closed_ui";
import * as compose_pm_pill from "./compose_pm_pill"; import * as compose_pm_pill from "./compose_pm_pill";
import * as compose_recipient from "./compose_recipient"; import * as compose_recipient from "./compose_recipient";
import * as compose_state from "./compose_state"; import * as compose_state from "./compose_state";
@ -212,6 +213,8 @@ export function dispatch_normal_event(event) {
description: noop, description: noop,
digest_emails_enabled: noop, digest_emails_enabled: noop,
digest_weekday: noop, digest_weekday: noop,
direct_message_initiator_group: noop,
direct_message_permission_group: noop,
email_changes_disabled: settings_account.update_email_change_display, email_changes_disabled: settings_account.update_email_change_display,
disallow_disposable_email_addresses: noop, disallow_disposable_email_addresses: noop,
inline_image_preview: noop, inline_image_preview: noop,
@ -229,7 +232,6 @@ export function dispatch_normal_event(event) {
name_changes_disabled: settings_account.update_name_change_display, name_changes_disabled: settings_account.update_name_change_display,
new_stream_announcements_stream_id: stream_ui_updates.update_announce_stream_option, new_stream_announcements_stream_id: stream_ui_updates.update_announce_stream_option,
org_type: noop, org_type: noop,
private_message_policy: compose_recipient.check_posting_policy_for_compose_box,
push_notifications_enabled: noop, push_notifications_enabled: noop,
require_unique_names: noop, require_unique_names: noop,
send_welcome_emails: noop, send_welcome_emails: noop,
@ -296,6 +298,17 @@ export function dispatch_normal_event(event) {
stream_settings_ui.update_stream_privacy_choices(key); stream_settings_ui.update_stream_privacy_choices(key);
} }
if (
key === "direct_message_initiator_group" ||
key === "direct_message_permission_group"
) {
settings_org.check_disable_direct_message_initiator_group_dropdown(
realm.realm_direct_message_permission_group,
);
compose_closed_ui.update_buttons_for_private();
compose_recipient.check_posting_policy_for_compose_box();
}
if (key === "edit_topic_policy") { if (key === "edit_topic_policy") {
message_live_update.rerender_messages_view(); message_live_update.rerender_messages_view();
} }

View File

@ -207,7 +207,6 @@ type simple_dropdown_realm_settings = Pick<
| "realm_create_web_public_stream_policy" | "realm_create_web_public_stream_policy"
| "realm_invite_to_stream_policy" | "realm_invite_to_stream_policy"
| "realm_user_group_edit_policy" | "realm_user_group_edit_policy"
| "realm_private_message_policy"
| "realm_add_custom_emoji_policy" | "realm_add_custom_emoji_policy"
| "realm_invite_to_realm_policy" | "realm_invite_to_realm_policy"
| "realm_wildcard_mention_policy" | "realm_wildcard_mention_policy"
@ -474,6 +473,8 @@ const dropdown_widget_map = new Map<string, DropdownWidget | null>([
["can_mention_group", null], ["can_mention_group", null],
["realm_can_create_public_channel_group", null], ["realm_can_create_public_channel_group", null],
["realm_can_create_private_channel_group", null], ["realm_can_create_private_channel_group", null],
["realm_direct_message_initiator_group", null],
["realm_direct_message_permission_group", null],
]); ]);
export function get_widget_for_dropdown_list_settings( export function get_widget_for_dropdown_list_settings(
@ -781,6 +782,8 @@ export function check_realm_settings_property_changed(elem: HTMLElement): boolea
case "realm_can_access_all_users_group": case "realm_can_access_all_users_group":
case "realm_can_create_public_channel_group": case "realm_can_create_public_channel_group":
case "realm_can_create_private_channel_group": case "realm_can_create_private_channel_group":
case "realm_direct_message_initiator_group":
case "realm_direct_message_permission_group":
proposed_val = get_dropdown_list_widget_setting_value($elem); proposed_val = get_dropdown_list_widget_setting_value($elem);
break; break;
case "realm_message_content_edit_limit_seconds": case "realm_message_content_edit_limit_seconds":
@ -974,6 +977,8 @@ export function populate_data_for_realm_settings_request(
const realm_group_settings_using_new_api_format = new Set([ const realm_group_settings_using_new_api_format = new Set([
"can_create_private_channel_group", "can_create_private_channel_group",
"can_create_public_channel_group", "can_create_public_channel_group",
"direct_message_initiator_group",
"direct_message_permission_group",
]); ]);
if (realm_group_settings_using_new_api_format.has(property_name)) { if (realm_group_settings_using_new_api_format.has(property_name)) {
const old_value = get_realm_settings_property_value( const old_value = get_realm_settings_property_value(

View File

@ -256,19 +256,6 @@ export const email_invite_to_realm_policy_values = {
}, },
}; };
export const private_message_policy_values = {
by_anyone: {
order: 1,
code: 1,
description: $t({defaultMessage: "Admins, moderators, members and guests"}),
},
disabled: {
order: 2,
code: 2,
description: $t({defaultMessage: "Direct messages disabled"}),
},
};
export const wildcard_mention_policy_values = { export const wildcard_mention_policy_values = {
by_everyone: { by_everyone: {
order: 1, order: 1,

View File

@ -95,9 +95,6 @@ export function get_organization_settings_options() {
options.common_policy_values = settings_components.get_sorted_options_list( options.common_policy_values = settings_components.get_sorted_options_list(
settings_config.common_policy_values, settings_config.common_policy_values,
); );
options.private_message_policy_values = settings_components.get_sorted_options_list(
settings_config.private_message_policy_values,
);
options.wildcard_mention_policy_values = settings_components.get_sorted_options_list( options.wildcard_mention_policy_values = settings_components.get_sorted_options_list(
settings_config.wildcard_mention_policy_values, settings_config.wildcard_mention_policy_values,
); );
@ -129,7 +126,6 @@ const simple_dropdown_properties = [
"realm_create_web_public_stream_policy", "realm_create_web_public_stream_policy",
"realm_invite_to_stream_policy", "realm_invite_to_stream_policy",
"realm_user_group_edit_policy", "realm_user_group_edit_policy",
"realm_private_message_policy",
"realm_add_custom_emoji_policy", "realm_add_custom_emoji_policy",
"realm_invite_to_realm_policy", "realm_invite_to_realm_policy",
"realm_wildcard_mention_policy", "realm_wildcard_mention_policy",
@ -373,6 +369,14 @@ function set_create_web_public_stream_dropdown_visibility() {
); );
} }
export function check_disable_direct_message_initiator_group_dropdown(current_value) {
if (current_value === user_groups.get_user_group_from_name("role:nobody").id) {
$("#realm_direct_message_initiator_group_widget").prop("disabled", true);
} else {
$("#realm_direct_message_initiator_group_widget").prop("disabled", false);
}
}
export function populate_realm_domains_label(realm_domains) { export function populate_realm_domains_label(realm_domains) {
if (!meta.loaded) { if (!meta.loaded) {
return; return;
@ -472,6 +476,11 @@ function update_dependent_subsettings(property_name) {
case "realm_enable_spectator_access": case "realm_enable_spectator_access":
set_create_web_public_stream_dropdown_visibility(); set_create_web_public_stream_dropdown_visibility();
break; break;
case "realm_direct_message_permission_group":
check_disable_direct_message_initiator_group_dropdown(
realm.realm_direct_message_permission_group,
);
break;
} }
} }
@ -491,6 +500,8 @@ export function discard_realm_property_element_changes(elem) {
case "realm_zulip_update_announcements_stream_id": case "realm_zulip_update_announcements_stream_id":
case "realm_default_code_block_language": case "realm_default_code_block_language":
case "realm_create_multiuse_invite_group": case "realm_create_multiuse_invite_group":
case "realm_direct_message_initiator_group":
case "realm_direct_message_permission_group":
case "realm_can_access_all_users_group": case "realm_can_access_all_users_group":
case "realm_can_create_public_channel_group": case "realm_can_create_public_channel_group":
case "realm_can_create_private_channel_group": case "realm_can_create_private_channel_group":
@ -744,7 +755,12 @@ export function set_up() {
maybe_disable_widgets(); maybe_disable_widgets();
} }
function set_up_dropdown_widget(setting_name, setting_options, setting_type) { function set_up_dropdown_widget(
setting_name,
setting_options,
setting_type,
custom_dropdown_widget_callback,
) {
const $save_discard_widget_container = $(`#id_${CSS.escape(setting_name)}`).closest( const $save_discard_widget_container = $(`#id_${CSS.escape(setting_name)}`).closest(
".settings-subsection-parent", ".settings-subsection-parent",
); );
@ -774,6 +790,9 @@ function set_up_dropdown_widget(setting_name, setting_options, setting_type) {
settings_components.save_discard_realm_settings_widget_status_handler( settings_components.save_discard_realm_settings_widget_status_handler(
$save_discard_widget_container, $save_discard_widget_container,
); );
if (custom_dropdown_widget_callback !== undefined) {
custom_dropdown_widget_callback(this.current_value);
}
}, },
tippy_props: { tippy_props: {
placement: "bottom-start", placement: "bottom-start",
@ -799,7 +818,17 @@ export function set_up_dropdown_widget_for_realm_group_settings() {
for (const setting_name of realm_group_permission_settings) { for (const setting_name of realm_group_permission_settings) {
const get_setting_options = () => const get_setting_options = () =>
user_groups.get_realm_user_groups_for_dropdown_list_widget(setting_name, "realm"); user_groups.get_realm_user_groups_for_dropdown_list_widget(setting_name, "realm");
set_up_dropdown_widget("realm_" + setting_name, get_setting_options, "group"); let dropdown_list_item_click_callback;
if (setting_name === "direct_message_permission_group") {
dropdown_list_item_click_callback =
check_disable_direct_message_initiator_group_dropdown;
}
set_up_dropdown_widget(
"realm_" + setting_name,
get_setting_options,
"group",
dropdown_list_item_click_callback,
);
} }
} }

View File

@ -278,6 +278,8 @@ const realm_schema = z.object({
realm_description: z.string(), realm_description: z.string(),
realm_digest_emails_enabled: NOT_TYPED_YET, realm_digest_emails_enabled: NOT_TYPED_YET,
realm_digest_weekday: NOT_TYPED_YET, realm_digest_weekday: NOT_TYPED_YET,
realm_direct_message_initiator_group: z.number(),
realm_direct_message_permission_group: z.number(),
realm_disallow_disposable_email_addresses: z.boolean(), realm_disallow_disposable_email_addresses: z.boolean(),
realm_domains: z.array( realm_domains: z.array(
z.object({ z.object({

View File

@ -568,7 +568,10 @@ export function initialize_everything(state_data) {
user_status.initialize(state_data.user_status); user_status.initialize(state_data.user_status);
compose_recipient.initialize(); compose_recipient.initialize();
compose_pm_pill.initialize({ compose_pm_pill.initialize({
on_pill_create_or_remove: compose_recipient.update_placeholder_text, on_pill_create_or_remove() {
compose_recipient.update_placeholder_text();
compose_recipient.check_posting_policy_for_compose_box();
},
}); });
compose_closed_ui.initialize(); compose_closed_ui.initialize();
compose_reply.initialize(); compose_reply.initialize();

View File

@ -24,6 +24,7 @@ import * as dialog_widget from "./dialog_widget";
import * as hash_util from "./hash_util"; import * as hash_util from "./hash_util";
import {$t, $t_html} from "./i18n"; import {$t, $t_html} from "./i18n";
import * as message_lists from "./message_lists"; import * as message_lists from "./message_lists";
import {user_can_send_direct_message} from "./message_util";
import * as message_view from "./message_view"; import * as message_view from "./message_view";
import * as muted_users from "./muted_users"; import * as muted_users from "./muted_users";
import * as overlays from "./overlays"; import * as overlays from "./overlays";
@ -32,7 +33,6 @@ import * as people from "./people";
import * as popover_menus from "./popover_menus"; import * as popover_menus from "./popover_menus";
import {hide_all} from "./popovers"; import {hide_all} from "./popovers";
import * as rows from "./rows"; import * as rows from "./rows";
import * as settings_config from "./settings_config";
import * as sidebar_ui from "./sidebar_ui"; import * as sidebar_ui from "./sidebar_ui";
import {current_user, realm} from "./state_data"; import {current_user, realm} from "./state_data";
import * as timerender from "./timerender"; import * as timerender from "./timerender";
@ -263,13 +263,13 @@ function get_user_card_popover_data(
.map((f) => user_profile.get_custom_profile_field_data(user, f, field_types)) .map((f) => user_profile.get_custom_profile_field_data(user, f, field_types))
.filter((f) => f.display_in_profile_summary && f.value !== undefined && f.value !== null); .filter((f) => f.display_in_profile_summary && f.value !== undefined && f.value !== null);
const user_id_string = user.user_id.toString();
const can_send_private_message =
user_can_send_direct_message(user_id_string) && is_active && !is_me;
const args = { const args = {
invisible_mode, invisible_mode,
can_send_private_message: can_send_private_message,
is_active &&
!is_me &&
realm.realm_private_message_policy !==
settings_config.private_message_policy_values.disabled.code,
display_profile_fields, display_profile_fields,
has_message_context, has_message_context,
is_active, is_active,

View File

@ -3,6 +3,7 @@ import type {z} from "zod";
import * as blueslip from "./blueslip"; import * as blueslip from "./blueslip";
import {FoldDict} from "./fold_dict"; import {FoldDict} from "./fold_dict";
import * as group_permission_settings from "./group_permission_settings"; import * as group_permission_settings from "./group_permission_settings";
import {$t} from "./i18n";
import * as settings_config from "./settings_config"; import * as settings_config from "./settings_config";
import type {StateData, user_group_schema} from "./state_data"; import type {StateData, user_group_schema} from "./state_data";
import {current_user} from "./state_data"; import {current_user} from "./state_data";
@ -225,6 +226,14 @@ export function is_user_in_group(user_group_id: number, user_id: number): boolea
return false; return false;
} }
function get_display_name_for_system_group_option(setting_name: string, name: string): string {
// We use a special label for the "Nobody" system group for clarity.
if (setting_name === "direct_message_permission_group" && name === "Nobody") {
return $t({defaultMessage: "Direct messages disabled"});
}
return name;
}
export function get_realm_user_groups_for_dropdown_list_widget( export function get_realm_user_groups_for_dropdown_list_widget(
setting_name: string, setting_name: string,
setting_type: "realm" | "stream" | "group", setting_type: "realm" | "stream" | "group",
@ -277,7 +286,7 @@ export function get_realm_user_groups_for_dropdown_list_widget(
throw new Error(`Unknown group name: ${group.name}`); throw new Error(`Unknown group name: ${group.name}`);
} }
return { return {
name: group.display_name, name: get_display_name_for_system_group_option(setting_name, group.display_name),
unique_id: user_group.id, unique_id: user_group.id,
}; };
}); });

View File

@ -0,0 +1,9 @@
{{#> compose_banner }}
<p class="banner_message">
{{error_message}}
{{#tr}}
<z-link>Learn more.</z-link>
{{#*inline "z-link"}}<a target="_blank" rel="noopener noreferrer" href="/help/restrict-direct-messages">{{> @partial-block}}</a>{{/inline}}
{{/tr}}
</p>
{{/compose_banner}}

View File

@ -110,6 +110,24 @@
</div> </div>
</div> </div>
<div id="org-direct-message-permissions" class="settings-subsection-parent">
<div class="subsection-header">
<h3>{{t "Direct message permissions" }}
{{> ../help_link_widget link="/help/restrict-direct-messages" }}
</h3>
{{> settings_save_discard_widget section_name="direct-message-permissions" }}
</div>
{{> ../dropdown_widget_with_label
widget_name="realm_direct_message_permission_group"
label=(t 'Who can authorize a direct message conversation')
value_type="number" }}
{{> ../dropdown_widget_with_label
widget_name="realm_direct_message_initiator_group"
label=(t 'Who can start a direct message conversation')
value_type="number" }}
</div>
<div id="org-msg-editing" class="settings-subsection-parent"> <div id="org-msg-editing" class="settings-subsection-parent">
<div class="subsection-header"> <div class="subsection-header">
<h3>{{t "Message editing" }} <h3>{{t "Message editing" }}
@ -336,15 +354,6 @@
{{> dropdown_options_widget option_values=common_policy_values}} {{> dropdown_options_widget option_values=common_policy_values}}
</select> </select>
</div> </div>
<div class="input-group">
<label for="realm_private_message_policy" class="settings-field-label">{{t "Who can use direct messages" }} ({{t "beta" }})
{{> ../help_link_widget link="/help/restrict-direct-messages" }}
</label>
<select name="realm_private_message_policy" class="setting-widget prop-element settings_select bootstrap-focus-style" id="id_realm_private_message_policy" data-setting-widget-type="number">
{{> dropdown_options_widget option_values=private_message_policy_values}}
</select>
</div>
</div> </div>
</div> </div>
</form> </form>

View File

@ -18,9 +18,6 @@
{{t 'Reply to selected conversation' }} {{t 'Reply to selected conversation' }}
{{tooltip_hotkey_hints "R"}} {{tooltip_hotkey_hints "R"}}
</template> </template>
<template id="compose_reply_direct_disabled_button_tooltip_template">
{{t 'You are not allowed to send direct messages in this organization.' }}
</template>
<template id="left_bar_compose_mobile_button_tooltip_template"> <template id="left_bar_compose_mobile_button_tooltip_template">
{{t 'New message' }} {{t 'New message' }}
{{tooltip_hotkey_hints "C"}} {{tooltip_hotkey_hints "C"}}

View File

@ -11,7 +11,7 @@ const {run_test, noop} = require("./lib/test");
const $ = require("./lib/zjquery"); const $ = require("./lib/zjquery");
const {current_user, page_params, realm, user_settings} = require("./lib/zpage_params"); const {current_user, page_params, realm, user_settings} = require("./lib/zpage_params");
const settings_config = zrequire("settings_config"); const user_groups = zrequire("user_groups");
set_global("document", { set_global("document", {
querySelector() {}, querySelector() {},
@ -115,6 +115,23 @@ const social = {
}; };
stream_data.add_sub(social); stream_data.add_sub(social);
const nobody = {
name: "role:nobody",
id: 1,
members: new Set([]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
const everyone = {
name: "role:everyone",
id: 2,
members: new Set([30]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
user_groups.initialize({realm_user_groups: [nobody, everyone]});
function test_ui(label, f) { function test_ui(label, f) {
// TODO: initialize data more aggressively. // TODO: initialize data more aggressively.
run_test(label, f); run_test(label, f);
@ -462,6 +479,8 @@ test_ui("finish", ({override, override_rewire}) => {
compose_state.set_message_type("private"); compose_state.set_message_type("private");
override(compose_pm_pill, "get_emails", () => "bob@example.com"); override(compose_pm_pill, "get_emails", () => "bob@example.com");
override(compose_pm_pill, "get_user_ids", () => []); override(compose_pm_pill, "get_user_ids", () => []);
override(realm, "realm_direct_message_permission_group", nobody.id);
override(realm, "realm_direct_message_initiator_group", everyone.id);
let compose_finished_event_checked = false; let compose_finished_event_checked = false;
$(document).on("compose_finished.zulip", () => { $(document).on("compose_finished.zulip", () => {
@ -817,11 +836,8 @@ test_ui("create_message_object", ({override, override_rewire}) => {
test_ui("DM policy disabled", ({override, override_rewire}) => { test_ui("DM policy disabled", ({override, override_rewire}) => {
// Disable dms in the organisation // Disable dms in the organisation
override( override(realm, "realm_direct_message_permission_group", nobody.id);
realm, override(realm, "realm_direct_message_initiator_group", everyone.id);
"realm_private_message_policy",
settings_config.private_message_policy_values.disabled.code,
);
let reply_disabled = false; let reply_disabled = false;
override_rewire(compose_closed_ui, "update_reply_button_state", (disabled = false) => { override_rewire(compose_closed_ui, "update_reply_button_state", (disabled = false) => {
reply_disabled = disabled; reply_disabled = disabled;
@ -839,6 +855,8 @@ test_ui("DM policy disabled", ({override, override_rewire}) => {
test_ui("narrow_button_titles", ({override}) => { test_ui("narrow_button_titles", ({override}) => {
override(narrow_state, "pm_ids_string", () => "31"); override(narrow_state, "pm_ids_string", () => "31");
override(narrow_state, "is_message_feed_visible", () => true); override(narrow_state, "is_message_feed_visible", () => true);
override(realm, "realm_direct_message_permission_group", everyone.id);
override(realm, "realm_direct_message_initiator_group", everyone.id);
compose_closed_ui.update_buttons_for_private(); compose_closed_ui.update_buttons_for_private();
assert.equal( assert.equal(
$("#new_conversation_button").text(), $("#new_conversation_button").text(),

View File

@ -5,10 +5,27 @@ const {strict: assert} = require("assert");
const {mock_banners} = require("./lib/compose_banner"); const {mock_banners} = require("./lib/compose_banner");
const {mock_esm, set_global, zrequire} = require("./lib/namespace"); const {mock_esm, set_global, zrequire} = require("./lib/namespace");
const {run_test, noop} = require("./lib/test"); const {run_test, noop} = require("./lib/test");
const blueslip = require("./lib/zblueslip");
const $ = require("./lib/zjquery"); const $ = require("./lib/zjquery");
const {realm} = require("./lib/zpage_params"); const {realm} = require("./lib/zpage_params");
const settings_config = zrequire("settings_config"); const user_groups = zrequire("user_groups");
const nobody = {
name: "role:nobody",
id: 1,
members: new Set([]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
const everyone = {
name: "role:everyone",
id: 2,
members: new Set([30]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
user_groups.initialize({realm_user_groups: [nobody, everyone]});
set_global("document", { set_global("document", {
to_$: () => $("document-stub"), to_$: () => $("document-stub"),
@ -365,6 +382,8 @@ test("reply_with_mention", ({override, override_rewire, mock_template}) => {
test("quote_and_reply", ({disallow, override, override_rewire}) => { test("quote_and_reply", ({disallow, override, override_rewire}) => {
override_rewire(compose_recipient, "on_compose_select_recipient_update", noop); override_rewire(compose_recipient, "on_compose_select_recipient_update", noop);
override_rewire(compose_reply, "selection_within_message_id", () => undefined); override_rewire(compose_reply, "selection_within_message_id", () => undefined);
override(realm, "realm_direct_message_permission_group", nobody.id);
override(realm, "realm_direct_message_initiator_group", everyone.id);
mock_banners(); mock_banners();
compose_state.set_message_type("stream"); compose_state.set_message_type("stream");
@ -508,6 +527,7 @@ test("on_narrow", ({override, override_rewire}) => {
}; };
people.add_active_user(bot); people.add_active_user(bot);
user_groups.initialize({realm_user_groups: [nobody, everyone]});
let cancel_called = false; let cancel_called = false;
override_rewire(compose_actions, "cancel", () => { override_rewire(compose_actions, "cancel", () => {
cancel_called = true; cancel_called = true;
@ -544,8 +564,8 @@ test("on_narrow", ({override, override_rewire}) => {
start_called = true; start_called = true;
}); });
narrowed_by_pm_reply = true; narrowed_by_pm_reply = true;
realm.realm_private_message_policy = realm.realm_direct_message_permission_group = nobody.id;
settings_config.private_message_policy_values.disabled.code; realm.realm_direct_message_initiator_group = everyone.id;
compose_actions.on_narrow({ compose_actions.on_narrow({
force_close: false, force_close: false,
trigger: "not-search", trigger: "not-search",
@ -560,8 +580,8 @@ test("on_narrow", ({override, override_rewire}) => {
}); });
assert.ok(start_called); assert.ok(start_called);
realm.realm_private_message_policy = realm.realm_direct_message_permission_group = everyone.id;
settings_config.private_message_policy_values.by_anyone.code; blueslip.expect("warn", "Unknown emails");
compose_actions.on_narrow({ compose_actions.on_narrow({
force_close: false, force_close: false,
trigger: "not-search", trigger: "not-search",

View File

@ -21,6 +21,7 @@ const settings_config = zrequire("settings_config");
const settings_data = mock_esm("../src/settings_data"); const settings_data = mock_esm("../src/settings_data");
const stream_data = zrequire("stream_data"); const stream_data = zrequire("stream_data");
const compose_recipient = zrequire("/compose_recipient"); const compose_recipient = zrequire("/compose_recipient");
const user_groups = zrequire("user_groups");
const me = { const me = {
email: "me@example.com", email: "me@example.com",
@ -39,6 +40,7 @@ const bob = {
email: "bob@example.com", email: "bob@example.com",
user_id: 32, user_id: 32,
full_name: "Bob", full_name: "Bob",
is_admin: true,
}; };
const social_sub = { const social_sub = {
@ -64,6 +66,29 @@ const welcome_bot = {
people.add_cross_realm_user(welcome_bot); people.add_cross_realm_user(welcome_bot);
const nobody = {
name: "role:nobody",
id: 1,
members: new Set([]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
const everyone = {
name: "role:everyone",
id: 2,
members: new Set([30]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
const admin = {
name: "role:administrators",
id: 3,
members: new Set([32]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
user_groups.initialize({realm_user_groups: [nobody, everyone, admin]});
function test_ui(label, f) { function test_ui(label, f) {
// The sloppy_$ flag lets us reuse setup from prior tests. // The sloppy_$ flag lets us reuse setup from prior tests.
run_test(label, (helpers) => { run_test(label, (helpers) => {
@ -148,6 +173,8 @@ test_ui("validate", ({mock_template}) => {
add_content_to_compose_box(); add_content_to_compose_box();
compose_state.private_message_recipient(""); compose_state.private_message_recipient("");
let pm_recipient_error_rendered = false; let pm_recipient_error_rendered = false;
realm.realm_direct_message_permission_group = everyone.id;
realm.realm_direct_message_initiator_group = everyone.id;
mock_template("compose_banner/compose_banner.hbs", false, (data) => { mock_template("compose_banner/compose_banner.hbs", false, (data) => {
assert.equal(data.classname, compose_banner.CLASSNAMES.missing_private_message_recipient); assert.equal(data.classname, compose_banner.CLASSNAMES.missing_private_message_recipient);
assert.equal( assert.equal(
@ -167,6 +194,16 @@ test_ui("validate", ({mock_template}) => {
assert.ok(compose_validate.validate()); assert.ok(compose_validate.validate());
assert.ok(!pm_recipient_error_rendered); assert.ok(!pm_recipient_error_rendered);
realm.realm_direct_message_initiator_group = admin.id;
assert.ok(compose_validate.validate());
assert.ok(!pm_recipient_error_rendered);
realm.realm_direct_message_permission_group = admin.id;
assert.ok(compose_validate.validate());
assert.ok(!pm_recipient_error_rendered);
realm.realm_direct_message_initiator_group = everyone.id;
realm.realm_direct_message_permission_group = everyone.id;
people.deactivate(bob); people.deactivate(bob);
let deactivated_user_error_rendered = false; let deactivated_user_error_rendered = false;
mock_template("compose_banner/compose_banner.hbs", false, (data) => { mock_template("compose_banner/compose_banner.hbs", false, (data) => {

View File

@ -28,6 +28,7 @@ const alert_words_ui = mock_esm("../src/alert_words_ui");
const attachments_ui = mock_esm("../src/attachments_ui"); const attachments_ui = mock_esm("../src/attachments_ui");
const audible_notifications = mock_esm("../src/audible_notifications"); const audible_notifications = mock_esm("../src/audible_notifications");
const bot_data = mock_esm("../src/bot_data"); const bot_data = mock_esm("../src/bot_data");
const compose_banner = mock_esm("../src/compose_banner");
const compose_pm_pill = mock_esm("../src/compose_pm_pill"); const compose_pm_pill = mock_esm("../src/compose_pm_pill");
const theme = mock_esm("../src/theme"); const theme = mock_esm("../src/theme");
const emoji_picker = mock_esm("../src/emoji_picker"); const emoji_picker = mock_esm("../src/emoji_picker");
@ -441,6 +442,7 @@ run_test("realm settings", ({override}) => {
current_user.is_admin = true; current_user.is_admin = true;
realm.realm_date_created = new Date("2023-01-01Z"); realm.realm_date_created = new Date("2023-01-01Z");
override(settings_org, "check_disable_direct_message_initiator_group_dropdown", noop);
override(settings_org, "sync_realm_settings", noop); override(settings_org, "sync_realm_settings", noop);
override(settings_bots, "update_bot_permissions_ui", noop); override(settings_bots, "update_bot_permissions_ui", noop);
override(settings_invites, "update_invite_user_panel", noop); override(settings_invites, "update_invite_user_panel", noop);
@ -449,6 +451,7 @@ run_test("realm settings", ({override}) => {
override(narrow_title, "redraw_title", noop); override(narrow_title, "redraw_title", noop);
override(navbar_alerts, "check_profile_incomplete", noop); override(navbar_alerts, "check_profile_incomplete", noop);
override(navbar_alerts, "show_profile_incomplete", noop); override(navbar_alerts, "show_profile_incomplete", noop);
override(compose_banner, "clear_errors", noop);
function test_electron_dispatch(event, fake_send_event) { function test_electron_dispatch(event, fake_send_event) {
with_overrides(({override}) => { with_overrides(({override}) => {
@ -573,6 +576,7 @@ run_test("realm settings", ({override}) => {
realm.realm_edit_topic_policy = 3; realm.realm_edit_topic_policy = 3;
realm.realm_authentication_methods = {Google: {enabled: false, available: true}}; realm.realm_authentication_methods = {Google: {enabled: false, available: true}};
realm.realm_can_create_public_channel_group = 1; realm.realm_can_create_public_channel_group = 1;
realm.realm_direct_message_permission_group = 1;
override(settings_org, "populate_auth_methods", noop); override(settings_org, "populate_auth_methods", noop);
dispatch(event); dispatch(event);
assert_same(realm.realm_create_multiuse_invite_group, 3); assert_same(realm.realm_create_multiuse_invite_group, 3);
@ -583,6 +587,7 @@ run_test("realm settings", ({override}) => {
Google: {enabled: true, available: true}, Google: {enabled: true, available: true},
}); });
assert_same(realm.realm_can_create_public_channel_group, 3); assert_same(realm.realm_can_create_public_channel_group, 3);
assert_same(realm.realm_direct_message_permission_group, 3);
assert_same(update_stream_privacy_choices_called, true); assert_same(update_stream_privacy_choices_called, true);
event = event_fixtures.realm__update_dict__icon; event = event_fixtures.realm__update_dict__icon;

View File

@ -374,6 +374,7 @@ exports.fixtures = {
Google: {enabled: true, available: true}, Google: {enabled: true, available: true},
}, },
can_create_public_channel_group: 3, can_create_public_channel_group: 3,
direct_message_permission_group: 3,
}, },
}, },

View File

@ -16,12 +16,13 @@ const stream_data = zrequire("stream_data");
const {Filter} = zrequire("../src/filter"); const {Filter} = zrequire("../src/filter");
const message_view = zrequire("message_view"); const message_view = zrequire("message_view");
const narrow_title = zrequire("narrow_title"); const narrow_title = zrequire("narrow_title");
const settings_config = zrequire("settings_config");
const recent_view_util = zrequire("recent_view_util"); const recent_view_util = zrequire("recent_view_util");
const inbox_util = zrequire("inbox_util"); const inbox_util = zrequire("inbox_util");
const message_lists = zrequire("message_lists"); const message_lists = zrequire("message_lists");
const user_groups = zrequire("user_groups");
mock_esm("../src/compose_banner", { mock_esm("../src/compose_banner", {
clear_errors() {},
clear_search_view_banner() {}, clear_search_view_banner() {},
}); });
const compose_pm_pill = mock_esm("../src/compose_pm_pill"); const compose_pm_pill = mock_esm("../src/compose_pm_pill");
@ -49,6 +50,9 @@ function set_filter(terms) {
message_lists.set_current({ message_lists.set_current({
data: { data: {
filter: new Filter(terms), filter: new Filter(terms),
fetch_status: {
has_found_newest: () => true,
},
}, },
}); });
} }
@ -78,6 +82,23 @@ const bot = {
is_bot: true, is_bot: true,
}; };
const nobody = {
name: "role:nobody",
id: 1,
members: new Set([]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
const everyone = {
name: "role:everyone",
id: 2,
members: new Set([5]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
user_groups.initialize({realm_user_groups: [nobody, everyone]});
run_test("empty_narrow_html", ({mock_template}) => { run_test("empty_narrow_html", ({mock_template}) => {
mock_template("empty_feed_notice.hbs", true, (_data, html) => html); mock_template("empty_feed_notice.hbs", true, (_data, html) => html);
@ -298,21 +319,8 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
), ),
); );
// organization has disabled sending direct messages realm.realm_direct_message_permission_group = everyone.id;
realm.realm_private_message_policy = realm.realm_direct_message_initiator_group = everyone.id;
settings_config.private_message_policy_values.disabled.code;
set_filter([["is", "dm"]]);
narrow_banner.show_empty_narrow_message();
assert.equal(
$(".empty_feed_notice_main").html(),
empty_narrow_html(
"translated: You are not allowed to send direct messages in this organization.",
),
);
// sending direct messages enabled
realm.realm_private_message_policy =
settings_config.private_message_policy_values.by_anyone.code;
set_filter([["is", "dm"]]); set_filter([["is", "dm"]]);
narrow_banner.show_empty_narrow_message(); narrow_banner.show_empty_narrow_message();
assert.equal( assert.equal(
@ -345,8 +353,7 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
); );
// organization has disabled sending direct messages // organization has disabled sending direct messages
realm.realm_private_message_policy = realm.realm_direct_message_permission_group = nobody.id;
settings_config.private_message_policy_values.disabled.code;
// prioritize information about invalid user(s) in narrow/search // prioritize information about invalid user(s) in narrow/search
set_filter([["dm", ["Yo"]]]); set_filter([["dm", ["Yo"]]]);
@ -369,7 +376,8 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
assert.equal( assert.equal(
$(".empty_feed_notice_main").html(), $(".empty_feed_notice_main").html(),
empty_narrow_html( empty_narrow_html(
"translated: You are not allowed to send direct messages in this organization.", "translated: Direct messages are disabled in this organization.",
'translated HTML: <a target="_blank" rel="noopener noreferrer" href="/help/restrict-direct-messages">Learn more.</a>',
), ),
); );
@ -393,13 +401,13 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
assert.equal( assert.equal(
$(".empty_feed_notice_main").html(), $(".empty_feed_notice_main").html(),
empty_narrow_html( empty_narrow_html(
"translated: You are not allowed to send direct messages in this organization.", "translated: Direct messages are disabled in this organization.",
'translated HTML: <a target="_blank" rel="noopener noreferrer" href="/help/restrict-direct-messages">Learn more.</a>',
), ),
); );
// sending direct messages enabled // sending direct messages enabled
realm.realm_private_message_policy = realm.realm_direct_message_permission_group = everyone.id;
settings_config.private_message_policy_values.by_anyone.code;
set_filter([["dm", "alice@example.com"]]); set_filter([["dm", "alice@example.com"]]);
narrow_banner.show_empty_narrow_message(); narrow_banner.show_empty_narrow_message();
assert.equal( assert.equal(
@ -433,8 +441,7 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
); );
// organization has disabled sending direct messages // organization has disabled sending direct messages
realm.realm_private_message_policy = realm.realm_direct_message_permission_group = nobody.id;
settings_config.private_message_policy_values.disabled.code;
// prioritize information about invalid user in narrow/search // prioritize information about invalid user in narrow/search
set_filter([["dm-including", ["Yo"]]]); set_filter([["dm-including", ["Yo"]]]);
@ -449,7 +456,8 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
assert.equal( assert.equal(
$(".empty_feed_notice_main").html(), $(".empty_feed_notice_main").html(),
empty_narrow_html( empty_narrow_html(
"translated: You are not allowed to send direct messages in this organization.", "translated: Direct messages are disabled in this organization.",
'translated HTML: <a target="_blank" rel="noopener noreferrer" href="/help/restrict-direct-messages">Learn more.</a>',
), ),
); );
@ -463,8 +471,8 @@ run_test("show_empty_narrow_message", ({mock_template}) => {
); );
// sending direct messages enabled // sending direct messages enabled
realm.realm_private_message_policy = realm.realm_direct_message_permission_group = everyone.id;
settings_config.private_message_policy_values.by_anyone.code; realm.realm_direct_message_permission_group = everyone.id;
set_filter([["dm-including", "alice@example.com"]]); set_filter([["dm-including", "alice@example.com"]]);
narrow_banner.show_empty_narrow_message(); narrow_banner.show_empty_narrow_message();
assert.equal( assert.equal(

View File

@ -19,6 +19,7 @@ const settings_data = mock_esm("../src/settings_data", {
const muted_users = zrequire("muted_users"); const muted_users = zrequire("muted_users");
const people = zrequire("people"); const people = zrequire("people");
const user_groups = zrequire("user_groups");
const welcome_bot = { const welcome_bot = {
email: "welcome-bot@example.com", email: "welcome-bot@example.com",
@ -59,6 +60,23 @@ function initialize() {
people._add_user(unknown_user); people._add_user(unknown_user);
} }
const nobody = {
name: "role:nobody",
id: 1,
members: new Set([]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
const everyone = {
name: "role:everyone",
id: 2,
members: new Set([30]),
is_system_group: true,
direct_subgroup_ids: new Set([]),
};
user_groups.initialize({realm_user_groups: [nobody, everyone]});
function test_people(label, f) { function test_people(label, f) {
run_test(label, (helpers) => { run_test(label, (helpers) => {
initialize(); initialize();
@ -1485,6 +1503,17 @@ test_people("get_user_by_id_assert_valid", ({override}) => {
assert.equal(user.email, charles.email); assert.equal(user.email, charles.email);
}); });
test_people("user_can_initiate_direct_message_thread", () => {
people.add_active_user(welcome_bot);
realm.realm_direct_message_initiator_group = nobody.id;
assert.ok(!people.user_can_initiate_direct_message_thread("32"));
// Can send if only bots and self are present.
assert.ok(people.user_can_initiate_direct_message_thread("4,30"));
realm.realm_direct_message_initiator_group = everyone.id;
assert.ok(people.user_can_initiate_direct_message_thread("32"));
});
// reset to native Date() // reset to native Date()
run_test("reset MockDate", () => { run_test("reset MockDate", () => {
MockDate.reset(); MockDate.reset();

View File

@ -22,7 +22,7 @@ import orjson
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import F from django.db.models import F, Q
from django.utils.html import escape from django.utils.html import escape
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -39,6 +39,8 @@ from zerver.lib.alert_words import get_alert_word_automaton
from zerver.lib.cache import cache_with_key, user_profile_delivery_email_cache_key from zerver.lib.cache import cache_with_key, user_profile_delivery_email_cache_key
from zerver.lib.create_user import create_user from zerver.lib.create_user import create_user
from zerver.lib.exceptions import ( from zerver.lib.exceptions import (
DirectMessageInitiationError,
DirectMessagePermissionError,
JsonableError, JsonableError,
MarkdownRenderingError, MarkdownRenderingError,
StreamDoesNotExistError, StreamDoesNotExistError,
@ -80,6 +82,7 @@ from zerver.lib.string_validation import check_stream_name
from zerver.lib.timestamp import timestamp_to_datetime from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.topic import participants_for_topic from zerver.lib.topic import participants_for_topic
from zerver.lib.url_preview.types import UrlEmbedData from zerver.lib.url_preview.types import UrlEmbedData
from zerver.lib.user_groups import is_any_user_in_group, is_user_in_group
from zerver.lib.user_message import UserMessageLite, bulk_insert_ums from zerver.lib.user_message import UserMessageLite, bulk_insert_ums
from zerver.lib.users import ( from zerver.lib.users import (
check_can_access_user, check_can_access_user,
@ -104,7 +107,6 @@ from zerver.models import (
) )
from zerver.models.clients import get_client from zerver.models.clients import get_client
from zerver.models.groups import SystemGroups from zerver.models.groups import SystemGroups
from zerver.models.realms import PrivateMessagePolicyEnum
from zerver.models.recipients import get_direct_message_group_user_ids from zerver.models.recipients import get_direct_message_group_user_ids
from zerver.models.scheduled_jobs import NotificationTriggers from zerver.models.scheduled_jobs import NotificationTriggers
from zerver.models.streams import get_stream, get_stream_by_id_in_realm from zerver.models.streams import get_stream, get_stream_by_id_in_realm
@ -1525,19 +1527,61 @@ def validate_stream_id_with_pm_notification(
return stream return stream
def check_private_message_policy( def check_can_send_direct_message(
realm: Realm, sender: UserProfile, user_profiles: Sequence[UserProfile] realm: Realm, sender: UserProfile, recipient_users: Sequence[UserProfile], recipient: Recipient
) -> None: ) -> None:
if realm.private_message_policy == PrivateMessagePolicyEnum.DISABLED: if sender.is_bot:
if sender.is_bot or ( return
len(user_profiles) == 1 and (user_profiles[0].is_bot or user_profiles[0] == sender)
): if all(user_profile.is_bot or user_profile.id == sender.id for user_profile in recipient_users):
# We allow direct messages only between users and bots or to oneself, return
# to avoid breaking the tutorial as well as automated
# notifications from system bots to users. direct_message_permission_group = realm.direct_message_permission_group
if (
not hasattr(direct_message_permission_group, "named_user_group")
or direct_message_permission_group.named_user_group.name != SystemGroups.EVERYONE
):
user_ids = [recipient_user.id for recipient_user in recipient_users] + [sender.id]
if not is_any_user_in_group(direct_message_permission_group, user_ids):
is_nobody_group = (
direct_message_permission_group.named_user_group.name == SystemGroups.NOBODY
)
raise DirectMessagePermissionError(is_nobody_group)
direct_message_initiator_group = realm.direct_message_initiator_group
if (
not hasattr(direct_message_initiator_group, "named_user_group")
or direct_message_initiator_group.named_user_group.name != SystemGroups.EVERYONE
):
if is_user_in_group(direct_message_initiator_group, sender):
return return
raise JsonableError(_("Direct messages are disabled in this organization.")) # TODO: This check is inefficient; we should in the future be able to cache
# on the Huddle object whether the conversation already exists, likely in the
# form of a `first_message_id` field, and be able to save doing this check in the
# common case that this is not the first message in a conversation.
if recipient.type == Recipient.PERSONAL:
recipient_user_profile = recipient_users[0]
previous_messages_exist = (
Message.objects.filter(
realm=realm,
recipient__type=Recipient.PERSONAL,
)
.filter(
Q(sender=sender, recipient=recipient)
| Q(sender=recipient_user_profile, recipient_id=sender.recipient_id)
)
.exists()
)
else:
assert recipient.type == Recipient.DIRECT_MESSAGE_GROUP
previous_messages_exist = Message.objects.filter(
realm=realm,
recipient=recipient,
).exists()
if not previous_messages_exist:
raise DirectMessageInitiationError
def check_sender_can_access_recipients( def check_sender_can_access_recipients(
@ -1692,8 +1736,6 @@ def check_message(
check_sender_can_access_recipients(realm, sender, user_profiles) check_sender_can_access_recipients(realm, sender, user_profiles)
check_private_message_policy(realm, sender, user_profiles)
recipients_for_user_creation_events = get_recipients_for_user_creation_events( recipients_for_user_creation_events = get_recipients_for_user_creation_events(
realm, sender, user_profiles realm, sender, user_profiles
) )
@ -1709,6 +1751,8 @@ def check_message(
except ValidationError as e: except ValidationError as e:
assert isinstance(e.messages[0], str) assert isinstance(e.messages[0], str)
raise JsonableError(e.messages[0]) raise JsonableError(e.messages[0])
check_can_send_direct_message(realm, sender, user_profiles, recipient)
else: else:
# This is defensive code--Addressee already validates # This is defensive code--Addressee already validates
# the message type. # the message type.

View File

@ -113,8 +113,14 @@ def get_usable_missed_message_address(address: str) -> MissedMessageEmailAddress
mm_address = MissedMessageEmailAddress.objects.select_related( mm_address = MissedMessageEmailAddress.objects.select_related(
"user_profile", "user_profile",
"user_profile__realm", "user_profile__realm",
# Fetch group settings that are needed to determine whether a user
# can send a direct message to a given recipient.
"user_profile__realm__can_access_all_users_group", "user_profile__realm__can_access_all_users_group",
"user_profile__realm__can_access_all_users_group__named_user_group", "user_profile__realm__can_access_all_users_group__named_user_group",
"user_profile__realm__direct_message_initiator_group",
"user_profile__realm__direct_message_initiator_group__named_user_group",
"user_profile__realm__direct_message_permission_group",
"user_profile__realm__direct_message_permission_group__named_user_group",
"message", "message",
"message__sender", "message__sender",
"message__recipient", "message__recipient",

View File

@ -1049,6 +1049,8 @@ group_setting_update_data_type = DictType(
("can_access_all_users_group", int), ("can_access_all_users_group", int),
("can_create_public_channel_group", group_setting_type), ("can_create_public_channel_group", group_setting_type),
("can_create_private_channel_group", group_setting_type), ("can_create_private_channel_group", group_setting_type),
("direct_message_initiator_group", group_setting_type),
("direct_message_permission_group", group_setting_type),
], ],
) )

View File

@ -519,6 +519,25 @@ class InvitationError(JsonableError):
self.daily_limit_reached: bool = daily_limit_reached self.daily_limit_reached: bool = daily_limit_reached
class DirectMessageInitiationError(JsonableError):
def __init__(self) -> None:
pass
@staticmethod
@override
def msg_format() -> str:
return _("You do not have permission to initiate direct message conversations.")
class DirectMessagePermissionError(JsonableError):
def __init__(self, is_nobody_group: bool) -> None:
if is_nobody_group:
msg = _("Direct messages are disabled in this organization.")
else:
msg = _("You do not have permission to send direct messages to this recipient.")
super().__init__(msg)
class AccessDeniedError(JsonableError): class AccessDeniedError(JsonableError):
http_status_code = 403 http_status_code = 403

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.8 on 2024-01-01 10:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0548_realmuserdefault_web_channel_default_view_and_more"),
]
operations = [
migrations.AddField(
model_name="realm",
name="direct_message_initiator_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
migrations.AddField(
model_name="realm",
name="direct_message_permission_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
]

View File

@ -0,0 +1,56 @@
# Generated by Django 4.2.8 on 2024-01-01 10:03
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import OuterRef
def set_default_value_for_direct_message_initiator_group_and_more(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
Realm = apps.get_model("zerver", "Realm")
NamedUserGroup = apps.get_model("zerver", "NamedUserGroup")
EVERYONE_GROUP_NAME = "role:everyone"
NOBODY_GROUP_NAME = "role:nobody"
PRIVATE_MESSAGE_POLICY_DISABLED = 2
Realm.objects.filter(
direct_message_initiator_group=None,
).update(
direct_message_initiator_group=NamedUserGroup.objects.filter(
name=EVERYONE_GROUP_NAME, realm=OuterRef("id"), is_system_group=True
).values("pk")
)
Realm.objects.filter(
direct_message_permission_group=None, private_message_policy=PRIVATE_MESSAGE_POLICY_DISABLED
).update(
direct_message_permission_group=NamedUserGroup.objects.filter(
name=NOBODY_GROUP_NAME, realm=OuterRef("id"), is_system_group=True
).values("pk")
)
Realm.objects.filter(
direct_message_permission_group=None,
).exclude(private_message_policy=PRIVATE_MESSAGE_POLICY_DISABLED).update(
direct_message_permission_group=NamedUserGroup.objects.filter(
name=EVERYONE_GROUP_NAME, realm=OuterRef("id"), is_system_group=True
).values("pk")
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("zerver", "0549_realm_direct_message_initiator_group_and_more"),
]
operations = [
migrations.RunPython(
set_default_value_for_direct_message_initiator_group_and_more,
elidable=True,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 4.2.8 on 2024-01-01 11:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0550_set_default_value_for_realm_direct_message_initiator_group_and_more"),
]
operations = [
migrations.AlterField(
model_name="realm",
name="direct_message_initiator_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
migrations.AlterField(
model_name="realm",
name="direct_message_permission_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
]

View File

@ -332,6 +332,18 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
"UserGroup", on_delete=models.RESTRICT, related_name="+" "UserGroup", on_delete=models.RESTRICT, related_name="+"
) )
# UserGroup of which at least one member must be included as sender
# or recipient in all personal and group direct messages.
direct_message_initiator_group = models.ForeignKey(
"UserGroup", on_delete=models.RESTRICT, related_name="+"
)
# UserGroup whose members must be included as sender or recipient in all
# direct messages.
direct_message_permission_group = models.ForeignKey(
"UserGroup", on_delete=models.RESTRICT, related_name="+"
)
# on_delete field here is set to RESTRICT because we don't want to allow # on_delete field here is set to RESTRICT because we don't want to allow
# deleting a user group in case it is referenced by this setting. # deleting a user group in case it is referenced by this setting.
# We are not using PROTECT since we want to allow deletion of user groups # We are not using PROTECT since we want to allow deletion of user groups
@ -724,11 +736,31 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
default_group_name=SystemGroups.MEMBERS, default_group_name=SystemGroups.MEMBERS,
id_field_name="can_create_private_channel_group_id", id_field_name="can_create_private_channel_group_id",
), ),
direct_message_initiator_group=GroupPermissionSetting(
require_system_group=False,
allow_internet_group=False,
allow_owners_group=True,
allow_nobody_group=True,
allow_everyone_group=True,
default_group_name=SystemGroups.EVERYONE,
id_field_name="direct_message_initiator_group_id",
),
direct_message_permission_group=GroupPermissionSetting(
require_system_group=False,
allow_internet_group=False,
allow_owners_group=True,
allow_nobody_group=True,
allow_everyone_group=True,
default_group_name=SystemGroups.EVERYONE,
id_field_name="direct_message_permission_group_id",
),
) )
REALM_PERMISSION_GROUP_SETTINGS_WITH_NEW_API_FORMAT = [ REALM_PERMISSION_GROUP_SETTINGS_WITH_NEW_API_FORMAT = [
"can_create_private_channel_group", "can_create_private_channel_group",
"can_create_public_channel_group", "can_create_public_channel_group",
"direct_message_initiator_group",
"direct_message_permission_group",
] ]
DIGEST_WEEKDAY_VALUES = [0, 1, 2, 3, 4, 5, 6] DIGEST_WEEKDAY_VALUES = [0, 1, 2, 3, 4, 5, 6]
@ -1092,6 +1124,10 @@ def get_realm_with_settings(realm_id: int) -> Realm:
"can_create_public_channel_group__named_user_group", "can_create_public_channel_group__named_user_group",
"can_create_private_channel_group", "can_create_private_channel_group",
"can_create_private_channel_group__named_user_group", "can_create_private_channel_group__named_user_group",
"direct_message_initiator_group",
"direct_message_initiator_group__named_user_group",
"direct_message_permission_group",
"direct_message_permission_group__named_user_group",
).get(id=realm_id) ).get(id=realm_id)

View File

@ -780,6 +780,8 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings):
"create_multiuse_invite_group", "create_multiuse_invite_group",
"create_web_public_stream_policy", "create_web_public_stream_policy",
"delete_own_message_policy", "delete_own_message_policy",
"direct_message_initiator_group",
"direct_message_permission_group",
"edit_topic_policy", "edit_topic_policy",
"invite_to_stream_policy", "invite_to_stream_policy",
"invite_to_realm_policy", "invite_to_realm_policy",
@ -913,6 +915,10 @@ def get_user_profile_by_id(user_profile_id: int) -> UserProfile:
"realm", "realm",
"realm__can_access_all_users_group", "realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group", "realm__can_access_all_users_group__named_user_group",
"realm__direct_message_initiator_group",
"realm__direct_message_initiator_group__named_user_group",
"realm__direct_message_permission_group",
"realm__direct_message_permission_group__named_user_group",
"bot_owner", "bot_owner",
).get(id=user_profile_id) ).get(id=user_profile_id)
@ -963,6 +969,10 @@ def get_user_by_delivery_email(email: str, realm: "Realm") -> UserProfile:
"realm", "realm",
"realm__can_access_all_users_group", "realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group", "realm__can_access_all_users_group__named_user_group",
"realm__direct_message_initiator_group",
"realm__direct_message_initiator_group__named_user_group",
"realm__direct_message_permission_group",
"realm__direct_message_permission_group__named_user_group",
"bot_owner", "bot_owner",
).get(delivery_email__iexact=email.strip(), realm=realm) ).get(delivery_email__iexact=email.strip(), realm=realm)
@ -1003,6 +1013,10 @@ def get_user(email: str, realm: "Realm") -> UserProfile:
"realm", "realm",
"realm__can_access_all_users_group", "realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group", "realm__can_access_all_users_group__named_user_group",
"realm__direct_message_initiator_group",
"realm__direct_message_initiator_group__named_user_group",
"realm__direct_message_permission_group",
"realm__direct_message_permission_group__named_user_group",
"bot_owner", "bot_owner",
).get(email__iexact=email.strip(), realm=realm) ).get(email__iexact=email.strip(), realm=realm)

View File

@ -4306,6 +4306,37 @@ paths:
description: | description: |
The day of the week when the organization will send The day of the week when the organization will send
its weekly digest email to inactive users. its weekly digest email to inactive users.
direct_message_initiator_group:
allOf:
- description: |
A [group-setting value](/api/group-setting-values) defining the set of
users who have permission to start a new direct message conversation
involving other non-bot users. Users who are outside this group and attempt
to send the first direct message to a given collection of recipient users
will receive an error, unless all other recipients are bots or the sender.
**Changes**: New in Zulip 9.0 (feature level 270).
Previously, access to send direct messages was controlled by the
`private_message_policy` realm setting, which supported values of
1 (enabled) and 2 (disabled).
- $ref: "#/components/schemas/GroupSettingValue"
direct_message_permission_group:
allOf:
- description: |
A [group-setting value](/api/group-setting-values) defining the set of
users who have permission to fully use direct messages. Users outside
this group can only send direct messages to conversations where all the
recipients are in this group, are bots, or are the sender, ensuring that
every direct message conversation will be visible to at least one user in
this group.
**Changes**: New in Zulip 9.0 (feature level 270).
Previously, access to send direct messages was controlled by the
`private_message_policy` realm setting, which supported values of
1 (enabled) and 2 (disabled).
- $ref: "#/components/schemas/GroupSettingValue"
disallow_disposable_email_addresses: disallow_disposable_email_addresses:
type: boolean type: boolean
description: | description: |
@ -15764,6 +15795,37 @@ paths:
- 2 = Nobody - 2 = Nobody
**Changes**: New in Zulip 3.0 (feature level 1). **Changes**: New in Zulip 3.0 (feature level 1).
realm_direct_message_initiator_group:
allOf:
- description: |
A [group-setting value](/api/group-setting-values) defining the set of
users who have permission to start a new direct message conversation
involving other non-bot users. Users who are outside this group and attempt
to send the first direct message to a given collection of recipient users
will receive an error, unless all other recipients are bots or the sender.
**Changes**: New in Zulip 9.0 (feature level 270).
Previously, access to send direct messages was controlled by the
`private_message_policy` realm setting, which supported values of
1 (enabled) and 2 (disabled).
- $ref: "#/components/schemas/GroupSettingValue"
realm_direct_message_permission_group:
allOf:
- description: |
A [group-setting value](/api/group-setting-values) defining the set of
users who have permission to fully use direct messages. Users outside
this group can only send direct messages to conversations where all the
recipients are in this group, are bots, or are the sender, ensuring that
every direct message conversation will be visible to at least one user in
this group.
**Changes**: New in Zulip 9.0 (feature level 270).
Previously, access to send direct messages was controlled by the
`private_message_policy` realm setting, which supported values of
1 (enabled) and 2 (disabled).
- $ref: "#/components/schemas/GroupSettingValue"
realm_user_group_edit_policy: realm_user_group_edit_policy:
type: integer type: integer
description: | description: |

View File

@ -142,6 +142,8 @@ class HomeTest(ZulipTestCase):
"realm_description", "realm_description",
"realm_digest_emails_enabled", "realm_digest_emails_enabled",
"realm_digest_weekday", "realm_digest_weekday",
"realm_direct_message_initiator_group",
"realm_direct_message_permission_group",
"realm_disallow_disposable_email_addresses", "realm_disallow_disposable_email_addresses",
"realm_domains", "realm_domains",
"realm_edit_topic_policy", "realm_edit_topic_policy",

View File

@ -26,13 +26,20 @@ from zerver.actions.message_send import (
internal_send_stream_message_by_name, internal_send_stream_message_by_name,
send_rate_limited_pm_notification_to_bot_owner, send_rate_limited_pm_notification_to_bot_owner,
) )
from zerver.actions.realm_settings import do_set_realm_property from zerver.actions.realm_settings import (
do_change_realm_permission_group_setting,
do_set_realm_property,
)
from zerver.actions.streams import do_change_stream_post_policy from zerver.actions.streams import do_change_stream_post_policy
from zerver.actions.user_groups import add_subgroups_to_user_group, check_add_user_group from zerver.actions.user_groups import add_subgroups_to_user_group, check_add_user_group
from zerver.actions.user_settings import do_change_user_setting from zerver.actions.user_settings import do_change_user_setting
from zerver.actions.users import do_change_can_forge_sender, do_deactivate_user from zerver.actions.users import do_change_can_forge_sender, do_deactivate_user
from zerver.lib.addressee import Addressee from zerver.lib.addressee import Addressee
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import (
DirectMessageInitiationError,
DirectMessagePermissionError,
JsonableError,
)
from zerver.lib.message import get_raw_unread_data, get_recent_private_conversations from zerver.lib.message import get_raw_unread_data, get_recent_private_conversations
from zerver.lib.message_cache import MessageDict from zerver.lib.message_cache import MessageDict
from zerver.lib.per_request_cache import flush_per_request_caches from zerver.lib.per_request_cache import flush_per_request_caches
@ -60,7 +67,7 @@ from zerver.models import (
) )
from zerver.models.constants import MAX_TOPIC_NAME_LENGTH from zerver.models.constants import MAX_TOPIC_NAME_LENGTH
from zerver.models.groups import SystemGroups from zerver.models.groups import SystemGroups
from zerver.models.realms import PrivateMessagePolicyEnum, WildcardMentionPolicyEnum, get_realm from zerver.models.realms import WildcardMentionPolicyEnum, get_realm
from zerver.models.recipients import get_or_create_direct_message_group from zerver.models.recipients import get_or_create_direct_message_group
from zerver.models.streams import get_stream from zerver.models.streams import get_stream
from zerver.models.users import get_system_bot, get_user from zerver.models.users import get_system_bot, get_user
@ -2412,27 +2419,169 @@ class PersonalMessageSendTest(ZulipTestCase):
receiver=self.example_user("othello"), receiver=self.example_user("othello"),
) )
def test_private_message_policy(self) -> None: def test_direct_message_initiator_group_setting(self) -> None:
""" """
Tests that PrivateMessagePolicyEnum.DISABLED works correctly. Tests that direct_message_initiator_group_setting works correctly.
""" """
user_profile = self.example_user("hamlet") user_profile = self.example_user("hamlet")
polonius = self.example_user("polonius")
admin = self.example_user("iago")
cordelia = self.example_user("cordelia")
realm = user_profile.realm
direct_message_group_1 = [user_profile, admin, polonius]
direct_message_group_2 = [user_profile, admin, polonius, cordelia]
administrators_system_group = NamedUserGroup.objects.get(
name=SystemGroups.ADMINISTRATORS, realm=realm, is_system_group=True
)
self.login_user(user_profile) self.login_user(user_profile)
do_set_realm_property( self.send_personal_message(user_profile, polonius)
user_profile.realm, do_change_realm_permission_group_setting(
"private_message_policy", realm,
PrivateMessagePolicyEnum.DISABLED, "direct_message_initiator_group",
administrators_system_group,
acting_user=None, acting_user=None,
) )
with self.assertRaises(JsonableError):
self.send_personal_message(user_profile, self.example_user("cordelia")) # We can send to Polonius because we'd previously messaged him.
self.send_personal_message(user_profile, polonius)
# Tests if we can send messages to self irrespective of the value of the setting.
self.send_personal_message(user_profile, user_profile)
# We cannot send to users with whom we does not have any direct message conversation.
with self.assertRaises(DirectMessageInitiationError) as direct_message_initiation_error:
self.send_personal_message(user_profile, cordelia)
self.assertEqual(
str(direct_message_initiation_error.exception),
"You do not have permission to initiate direct message conversations.",
)
with self.assertRaises(DirectMessageInitiationError):
self.send_personal_message(user_profile, admin)
# Have the administrator send a message, and verify that allows the user to reply.
self.send_personal_message(admin, user_profile)
with self.assert_database_query_count(16):
self.send_personal_message(user_profile, admin)
# Tests that user cannot initiate direct message thread in groups.
with self.assertRaises(DirectMessageInitiationError):
self.send_group_direct_message(user_profile, direct_message_group_1)
# Have the administrator send a message to the direct message group, and verify
# that allows the user to reply.
self.send_group_direct_message(admin, direct_message_group_1)
with self.assert_database_query_count(20):
self.send_group_direct_message(user_profile, direct_message_group_1)
# We cannot sent to `direct_message_group_2` as no message has been sent to this group yet.
with self.assertRaises(DirectMessageInitiationError):
self.send_group_direct_message(user_profile, direct_message_group_2)
bot_profile = self.create_test_bot("testbot", user_profile) bot_profile = self.create_test_bot("testbot", user_profile)
notification_bot = get_system_bot("notification-bot@zulip.com", user_profile.realm_id) notification_bot = get_system_bot("notification-bot@zulip.com", user_profile.realm_id)
# Tests if messages to and from bots are allowed irrespective of the value of the setting.
self.send_personal_message(user_profile, notification_bot) self.send_personal_message(user_profile, notification_bot)
self.send_personal_message(user_profile, bot_profile) self.send_personal_message(user_profile, bot_profile)
self.send_personal_message(bot_profile, user_profile) self.send_personal_message(bot_profile, user_profile)
# Tests if the permission works when the setting is set to a combination of
# groups and users.
user_group = self.create_or_update_anonymous_group_for_setting(
[user_profile],
[administrators_system_group],
)
do_change_realm_permission_group_setting(
realm,
"direct_message_initiator_group",
user_group,
acting_user=None,
)
self.send_personal_message(user_profile, cordelia)
def test_direct_message_permission_group_setting(self) -> None:
"""
Tests that direct_message_permission_group_setting works correctly.
"""
user_profile = self.example_user("hamlet")
cordelia = self.example_user("cordelia")
polonius = self.example_user("polonius")
admin = self.example_user("iago")
realm = user_profile.realm
direct_message_group = [user_profile, cordelia, admin]
direct_message_group_without_admin = [user_profile, cordelia, polonius]
administrators_system_group = NamedUserGroup.objects.get(
name=SystemGroups.ADMINISTRATORS, realm=realm, is_system_group=True
)
nobody_system_group = NamedUserGroup.objects.get(
name=SystemGroups.NOBODY, realm=realm, is_system_group=True
)
self.login_user(user_profile)
do_change_realm_permission_group_setting(
realm,
"direct_message_permission_group",
administrators_system_group,
acting_user=None,
)
# Tests if the user is allowed to send to administrators.
with self.assert_database_query_count(16):
self.send_personal_message(user_profile, admin)
self.send_personal_message(admin, user_profile)
# Tests if we can send messages to self irrespective of the value of the setting.
self.send_personal_message(user_profile, user_profile)
# We cannot send direct messages unless one of the recipient is in the
# `direct_message_permission_group` (in this case, the
# `administrators_system_group`).
with self.assertRaises(DirectMessagePermissionError) as direct_message_permission_error:
self.send_personal_message(user_profile, cordelia)
self.assertEqual(
str(direct_message_permission_error.exception),
"You do not have permission to send direct messages to this recipient.",
)
# We can send to this direct message group as it has administrator as one of the
# recipient.
with self.assert_database_query_count(24):
self.send_group_direct_message(user_profile, direct_message_group)
self.send_group_direct_message(admin, direct_message_group)
# But this one does not have an administrator. So, it should throw an error.
with self.assertRaises(DirectMessagePermissionError):
self.send_group_direct_message(user_profile, direct_message_group_without_admin)
bot_profile = self.create_test_bot("testbot", user_profile)
notification_bot = get_system_bot("notification-bot@zulip.com", user_profile.realm_id)
# Tests if messages to and from bots are allowed irrespective of the value of the setting.
self.send_personal_message(user_profile, notification_bot)
self.send_personal_message(user_profile, bot_profile)
self.send_personal_message(bot_profile, user_profile)
# Tests if the permission works when the setting is set to a combination of
# groups and users.
user_group = self.create_or_update_anonymous_group_for_setting(
[user_profile],
[administrators_system_group],
)
do_change_realm_permission_group_setting(
realm,
"direct_message_permission_group",
user_group,
acting_user=None,
)
self.send_personal_message(user_profile, cordelia)
do_change_realm_permission_group_setting(
realm,
"direct_message_permission_group",
nobody_system_group,
acting_user=None,
)
with self.assertRaises(DirectMessagePermissionError) as direct_message_permission_error:
self.send_personal_message(user_profile, cordelia)
self.assertEqual(
str(direct_message_permission_error.exception),
"Direct messages are disabled in this organization.",
)
def test_non_ascii_personal(self) -> None: def test_non_ascii_personal(self) -> None:
""" """
Sending a direct message containing non-ASCII characters succeeds. Sending a direct message containing non-ASCII characters succeeds.
@ -2678,7 +2827,15 @@ class InternalPrepTest(ZulipTestCase):
Test that a user can send a direct message to themselves and to a bot in a DM disabled organization Test that a user can send a direct message to themselves and to a bot in a DM disabled organization
""" """
sender = self.example_user("hamlet") sender = self.example_user("hamlet")
sender.realm.private_message_policy = PrivateMessagePolicyEnum.DISABLED nobody_system_group = NamedUserGroup.objects.get(
name=SystemGroups.NOBODY, realm=sender.realm, is_system_group=True
)
do_change_realm_permission_group_setting(
sender.realm,
"direct_message_permission_group",
nobody_system_group,
acting_user=None,
)
sender.realm.save() sender.realm.save()
# Create a non-bot user # Create a non-bot user

View File

@ -149,6 +149,8 @@ def update_realm(
bot_creation_policy: Optional[Json[BotCreationPolicyEnum]] = None, bot_creation_policy: Optional[Json[BotCreationPolicyEnum]] = None,
can_create_public_channel_group: Optional[Json[GroupSettingChangeRequest]] = None, can_create_public_channel_group: Optional[Json[GroupSettingChangeRequest]] = None,
can_create_private_channel_group: Optional[Json[GroupSettingChangeRequest]] = None, can_create_private_channel_group: Optional[Json[GroupSettingChangeRequest]] = None,
direct_message_initiator_group: Optional[Json[GroupSettingChangeRequest]] = None,
direct_message_permission_group: Optional[Json[GroupSettingChangeRequest]] = None,
create_web_public_stream_policy: Optional[Json[CreateWebPublicStreamPolicyEnum]] = None, create_web_public_stream_policy: Optional[Json[CreateWebPublicStreamPolicyEnum]] = None,
invite_to_stream_policy: Optional[Json[CommonPolicyEnum]] = None, invite_to_stream_policy: Optional[Json[CommonPolicyEnum]] = None,
move_messages_between_streams_policy: Optional[ move_messages_between_streams_policy: Optional[