本文来分析和总结Telegram的Windows、Linux和mac的基于Qt的桌面客户端的功能限制的实现流程。由于TG源代码量很大,不能全盘分析,此处只是有针对性地关注TG的限制功能是如何在客户端实现的。实际上,部分功能限制,比如转发消息限制是通过服务器控制的,而下载文件和复制消息文本是客户端限制的。这里仅讨论客户端上的限制是如何实现的。

首先我们要通过前文记录的方式生成一个VS项目,这里不再赘述。

在项目中,我们直接通过全局搜索查找TG用户界面上的文本字符串即可快速从 lang.strings 定位相关功能的UI代码位置。

限制保存图片、视频

在用户界面上,有一个 Save As... 的菜单,搜索这个字符串会找到的语言翻译文件中的"lng_mediaview_save_as" = "Save As..." 。再搜索 lng_mediaview_save_as 会找到对应的UI代码

, _docSaveAs(_widget, tr::lng_mediaview_save_as(tr::now), st::mediaviewFileLink)

这是那个菜单的按钮构造函数。以及另一处

	if (!hasCopyMediaRestriction()) {
		addAction(
			tr::lng_mediaview_save_as(tr::now),
			[=] { saveAs(); },
			&st::mediaMenuIconDownload);
	}

此处检查了媒体文件的限制,如果没有限制就添加一个另存为的菜单按钮。可以看出另存为操作的执行函数是 saveAs() 。我们先去看看 saveAs() 是怎么做的。这个函数有110多行,我们简略来看。

void OverlayWidget::saveAs() {
	if (showCopyMediaRestriction()) {
		return;
	}
	QString file;
	if (_document) {
		const auto &location = _document->location(true);
		const auto bytes = _documentMedia->bytes();
		if (!bytes.isEmpty() || location.accessEnable()) {
            //如果文件已经下载到磁盘,检查文件路径等信息,保存文件
		} else {
            //如果文件没有下载到磁盘,比如没加载完全的视频,交给handler继续处理
			DocumentSaveClickHandler::SaveAndTrack(
				_message ? _message->fullId() : FullMsgId(),
				_document,
				DocumentSaveClickHandler::Mode::ToNewFile);
		}
	} else if (_photo && _photo->hasVideo()) {
		constexpr auto large = Data::PhotoSize::Large;
		if (const auto bytes = _photoMedia->videoContent(large); !bytes.isEmpty()) {
			//如果已经有视频文件,那么直接另存一份
		} else {
			_photo->loadVideo(large, fileOrigin());
			_savePhotoVideoWhenLoaded = SavePhotoVideo::SaveAs;
            //加载完保存
		}
	} else {
		if (!_photo || !_photoMedia->loaded()) {
			return;
		}
        //加载和保存照片
}

这个函数适用于消息的右键菜单的另存为选项和图片视频查看浮层的另存为菜单选项。第一部分是处理下载文件的操作,第二部分是下载视频的操作,第三部分是下载照片的操作。后两个部分,也就是视频和照片是可能经过压缩的,与文件有所不同。

进一步,我们来看 DocumentSaveClickHandler::SaveAndTrack 是如何工作的。

void DocumentSaveClickHandler::SaveAndTrack(
		FullMsgId itemId,
		not_null<DocumentData*> document,
		Mode mode) {
	Save(itemId ? itemId : Data::FileOrigin(), document, mode);
	if (document->loading() && !document->loadingFilePath().isEmpty()) {
		if (const auto item = document->owner().message(itemId)) {
			Core::App().downloadManager().addLoading({
				.item = item,
				.document = document,
			});
		}
	}
}

SaveAndTrack 包含保存和跟踪下载进度两个部分。Save 函数内部不检查文件限制,仅仅是继续下载保存,同时这个文件被记录于 downloadManager。

看到这,基本确认权限检查就是 if (!hasCopyMediaRestriction()) 和 if (showCopyMediaRestriction()) 做的,其他位置没有重复检查。

bool OverlayWidget::hasCopyMediaRestriction() const {
	return (_history && !_history->peer->allowsForwarding())
		|| (_message && _message->forbidsSaving());
}

hasCopyMediaRestriction() 检查了会话对象的 allowsForwarding() 以及消息的 forbidsSaving() 。

bool OverlayWidget::showCopyMediaRestriction() {
	if (!hasCopyMediaRestriction()) {
		return false;
	}
	Ui::ShowMultilineToast({
		.parentOverride = _widget,
		.text = { _history->peer->isBroadcast()
			? tr::lng_error_nocopy_channel(tr::now)
			: tr::lng_error_nocopy_group(tr::now) },
	});
	return true;
}

showCopyMediaRestriction() 则直接调用了 hasCopyMediaRestriction() 。也就是检查了会话对象的 allowsForwarding() 以及消息的 forbidsSaving() 。

bool PeerData::allowsForwarding() const {
	if (const auto user = asUser()) {
		return true;
	} else if (const auto channel = asChannel()) {
		return channel->allowsForwarding();
	} else if (const auto chat = asChat()) {
		return chat->allowsForwarding();
	}
	return false;
}

allowsForwarding() 首先检查是不是用户自己操作,用户自己是允许转发自己的消息的。对于其他对话和频道,继续检查具体的 allowsForwarding() 。

bool ChannelData::allowsForwarding() const {
	return !(flags() & Flag::NoForwards);
}

频道的权限检查就是频道的标记。

bool ChatData::allowsForwarding() const {
	return !(flags() & Flag::NoForwards);
}

同样的,会话的权限检查也是标记。那么会话的flag是在哪里初始化的呢?通过查找引用发现在多出使用了setflags。

虽然能够找到set和add的使用位置,但是更应该关注那里使用了flag。使用的方法有两个:

	[[nodiscard]] auto flags() const {
		return _flags.current();
	}
	[[nodiscard]] auto flagsValue() const {
		return _flags.value();
	}

那么就要看看 current() 和 value()是怎么做的。

	auto current() const {
		return _value;
	}
	auto value() const {
		return _changes.events_starting_with({
			Type::from_raw(kEssential),
			_value });
	}

current 比较简单。value() 则还使用了事件流,比较复杂,不太理解。

限制复制消息文本

复制消息的菜单文本是 Copy Text 。对应的翻译文本是 "lng_context_copy_text" = "Copy Text" 。借助VS查找使用位置的功能,出现 lng_context_copy_text 的代码包括

				if (msg
					&& !link
					&& (view->hasVisibleText()
						|| mediaHasTextForCopy
						|| item->Has<HistoryMessageLogEntryOriginal>())) {
					_menu->addAction(tr::lng_context_copy_text(tr::now), [=] {
						copyContextText(itemId);
					}, &st::menuIconCopy);
				}

此处的上下文表明当消息不是链接、有可见文本、媒体有文本、 item->Has() 的时候显示复制文本的菜单选项。最后这个has函数难以理解,暂时没看懂。
上边这部分代码仅仅是允许复制的情况下出现复制选项的条件,而不是故意的限制。故意的限制需要看更外层的判断。

奇怪的是,向上查找限制条件的时候却没有找到判断代码。于是只能看看受限的条件下文本时什么,从那方面下手。不幸的是,受限的情况下桌面版的菜单没有出现受限的提示,而仅仅去掉了不允许的菜单,剩下复制链接和举报两个菜单。仔细观察,发现上面的代码来自于 admin文件,可能是仅限于会话中有管理权限的用户。我们继续查看其它位置的 lng_context_copy_text。

				if (!item->isService()
					&& view
					&& actionText.isEmpty()
					&& !hasCopyRestriction(item)
					&& (view->hasVisibleText() || mediaHasTextForCopy)) {
					_menu->addAction(tr::lng_context_copy_text(tr::now), [=] {
						copyContextText(itemId);
					}, &st::menuIconCopy);
				}

这是位于history_inner_widget.cpp 的代码。可以看到其中出现了 hasCopyRestriction() 这个限制检测。

		if (!link
			&& (view->hasVisibleText() || mediaHasTextForCopy)
			&& !list->hasCopyRestriction(view->data())) {
			const auto asGroup = (request.pointState != PointState::GroupPart);
			result->addAction(tr::lng_context_copy_text(tr::now), [=] {
				if (const auto item = owner->message(itemId)) {
					if (!list->showCopyRestriction(item)) {
						if (asGroup) {
							if (const auto group = owner->groups().find(item)) {
								TextUtilities::SetClipboardText(HistoryGroupText(group));
								return;
							}
						}
						TextUtilities::SetClipboardText(HistoryItemText(item));
					}
				}
			}, &st::menuIconCopy);
		}

另一处的代码也使用了 hasCopyRestriction() 。

bool ListWidget::hasCopyRestriction(HistoryItem *item) const {
	return _delegate->listCopyRestrictionType(item)
		!= CopyRestrictionType::None;
}

bool ListWidget::showCopyRestriction(HistoryItem *item) {
	const auto type = _delegate->listCopyRestrictionType(item);
	if (type == CopyRestrictionType::None) {
		return false;
	}
	Ui::ShowMultilineToast({
		.parentOverride = Window::Show(_controller).toastParent(),
		.text = { (type == CopyRestrictionType::Channel)
			? tr::lng_error_nocopy_channel(tr::now)
			: tr::lng_error_nocopy_group(tr::now) },
	});
	return true;
}

到这里,限制显示菜单的关键代码已经找到了。我们再去检查检查点击菜单之后的操作是否受到限制。其实可以直接看到复制文本就是简单的 TextUtilities::SetClipboardText ,想必应该不会有更复杂的检查了。

不过注意,选择文本的时候的时候有一个另外的限制

bool ListWidget::hasCopyRestrictionForSelected() const {
	if (hasCopyRestriction()) {
		return true;
	}
	if (_selected.empty()) {
		if (_selectedTextItem && _selectedTextItem->forbidsForward()) {
			return true;
		}
	}
	for (const auto &[itemId, selection] : _selected) {
		if (const auto item = session().data().message(itemId)) {
			if (item->forbidsForward()) {
				return true;
			}
		}
	}
	return false;
}

限制导出历史记录

TG桌面版的一个特色功能是可以导出消息记录为HTML或者JSON格式。但是这个功能也受到限制条件约束。在允许导出的会话中,对应的菜单文本是 Export chat history ,而受限的情况下不显示这个菜单。所以我们首先查找这个字符串。然而找不到这个字符串,有可能因为这是桌面版独有的功能,没有做翻译吧。那么从同一个菜单列表里的其他选项来看,包括 leave channel ,可以从这里找菜单的渲染选项,对应的翻译函数是 lng_profile_leave_channel 。使用位置包括 info_profile_actions.cpp 和 window_peer_menu.cpp,在 window_peer_menu.cpp 文件中查找 export 找到了添加导出菜单的代码。具体代码分别如下。

void Filler::addExportChat() {
	if (_thread->asTopic() || !_peer->canExportChatHistory()) {
		return;
	}
	const auto peer = _peer;
	_addAction(
		tr::lng_profile_export_chat(tr::now),
		[=] { PeerMenuExportChat(peer); },
		&st::menuIconExport);
}

bool PeerData::canExportChatHistory() const {
	if (isRepliesChat() || !allowsForwarding()) {
		return false;
	} else if (const auto channel = asChannel()) {
		if (!channel->amIn() && channel->invitePeekExpires()) {
			return false;
		}
	}
	for (const auto &block : _owner->history(id)->blocks) {
		for (const auto &message : block->messages) {
			if (!message->data()->isService()) {
				return true;
			}
		}
	}
	if (const auto from = migrateFrom()) {
		return from->canExportChatHistory();
	}
	return false;
}

如果会话是 topic 那么不允许导出记录,这可能是因为 topic 功能是新加的,还没有做导出的功能,不过我感觉目前的发展态势很可能不会有导出 topic 的设计,回头可以试试能不能导出 topic。canExportChatHistory() 不允许回复评论的会话导出、不允许不能转发的会话的导出、不允许自己没加入的channel导出、允许非官方服务会话导出,剩下的 migrateFrom() 不太清楚是什么,而其余情况则不允许导出。目前为止看的是如何限制了显示导出菜单。

再来看看点击到处菜单之后是否有限制。

void PeerMenuExportChat(not_null<PeerData*> peer) {
	Core::App().exportManager().start(peer);
}


void Manager::start(
		not_null<Main::Session*> session,
		const MTPInputPeer &singlePeer) {
	if (_panel) {
		_panel->activatePanel();
		return;
	}
	_controller = std::make_unique<Controller>(
		&session->mtp(),
		singlePeer);
	_panel = std::make_unique<View::PanelController>(
		session,
		_controller.get());
	session->account().sessionChanges(
	) | rpl::filter([=](Main::Session *value) {
		return (value != session);
	}) | rpl::start_with_next([=] {
		stop();
	}, _panel->lifetime());

	_viewChanges.fire(_panel.get());

	_panel->stopRequests(
	) | rpl::start_with_next([=] {
		LOG(("Export Info: Stop requested."));
		stop();
	}, _controller->lifetime());
}

看到其中没有额外检测限制。不过似乎export功能是额外的网络通信方法,而不是直接掉用抓取消息的方法,所以有可能服务器端会检测并限制。具体的 操作是 _controller 对象继续完成。

void ControllerObject::exportNext() {
	if (++_stepIndex >= _steps.size()) {
		if (ioCatchError(_writer->finish())) {
			return;
		}
		_api.finishExport([=] {
			setFinishedState();
		});
		return;
	}

	const auto step = _steps[_stepIndex];
	switch (step) {
	case Step::Initializing: return initialize();
	case Step::DialogsList: return collectDialogsList();
	case Step::PersonalInfo: return exportPersonalInfo();
	case Step::Userpics: return exportUserpics();
	case Step::Contacts: return exportContacts();
	case Step::Sessions: return exportSessions();
	case Step::OtherData: return exportOtherData();
	case Step::Dialogs: return exportDialogs();
	}
	Unexpected("Step in ControllerObject::exportNext.");
}

void ControllerObject::initialize() {
	setState(stateInitializing());
	_api.startExport(_settings, &_stats, [=](ApiWrap::StartInfo info) {
		initialized(info);
	});
}

果然使用的是单独的 api 。

修改的部分

最后附上修改过程中的git patch

Index: Telegram/SourceFiles/history/view/history_view_list_widget.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp
--- a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp	(revision 89c6bb163eac63d5688106f7b34d9ede49b914e5)
+++ b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp	(date 1711436324994)
@@ -1425,73 +1425,31 @@
 }
 
 bool ListWidget::hasCopyRestriction(HistoryItem *item) const {
-	return _delegate->listCopyRestrictionType(item)
-		!= CopyRestrictionType::None;
+	return false;
 }
 
 bool ListWidget::hasCopyMediaRestriction(not_null<HistoryItem*> item) const {
-	return _delegate->listCopyMediaRestrictionType(item)
-		!= CopyRestrictionType::None;
+	return false;
 }
 
 bool ListWidget::showCopyRestriction(HistoryItem *item) {
-	const auto type = _delegate->listCopyRestrictionType(item);
-	if (type == CopyRestrictionType::None) {
-		return false;
-	}
-	_controller->showToast((type == CopyRestrictionType::Channel)
-		? tr::lng_error_nocopy_channel(tr::now)
-		: tr::lng_error_nocopy_group(tr::now));
-	return true;
+	return false;
 }
 
 bool ListWidget::showCopyMediaRestriction(not_null<HistoryItem*> item) {
-	const auto type = _delegate->listCopyMediaRestrictionType(item);
-	if (type == CopyRestrictionType::None) {
-		return false;
-	}
-	_controller->showToast((type == CopyRestrictionType::Channel)
-		? tr::lng_error_nocopy_channel(tr::now)
-		: tr::lng_error_nocopy_group(tr::now));
-	return true;
+	return false;
 }
 
 bool ListWidget::hasCopyRestrictionForSelected() const {
-	if (hasCopyRestriction()) {
-		return true;
-	}
-	if (_selected.empty()) {
-		if (_selectedTextItem && _selectedTextItem->forbidsForward()) {
-			return true;
-		}
-	}
-	for (const auto &[itemId, selection] : _selected) {
-		if (const auto item = session().data().message(itemId)) {
-			if (item->forbidsForward()) {
-				return true;
-			}
-		}
-	}
 	return false;
 }
 
 bool ListWidget::showCopyRestrictionForSelected() {
-	if (_selected.empty()) {
-		if (_selectedTextItem && showCopyRestriction(_selectedTextItem)) {
-			return true;
-		}
-	}
-	for (const auto &[itemId, selection] : _selected) {
-		if (showCopyRestriction(session().data().message(itemId))) {
-			return true;
-		}
-	}
 	return false;
 }
 
 bool ListWidget::hasSelectRestriction() const {
-	return _delegate->listSelectRestrictionType()
-		!= CopyRestrictionType::None;
+	return false;
 }
 
 auto ListWidget::findViewForPinnedTracking(int top) const
Index: Telegram/SourceFiles/data/data_channel.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/Telegram/SourceFiles/data/data_channel.cpp b/Telegram/SourceFiles/data/data_channel.cpp
--- a/Telegram/SourceFiles/data/data_channel.cpp	(revision 89c6bb163eac63d5688106f7b34d9ede49b914e5)
+++ b/Telegram/SourceFiles/data/data_channel.cpp	(date 1711436193284)
@@ -590,7 +590,7 @@
 }
 
 bool ChannelData::allowsForwarding() const {
-	return !(flags() & Flag::NoForwards);
+	return true;
 }
 
 bool ChannelData::canViewMembers() const {
Index: Telegram/SourceFiles/data/data_peer.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/Telegram/SourceFiles/data/data_peer.cpp b/Telegram/SourceFiles/data/data_peer.cpp
--- a/Telegram/SourceFiles/data/data_peer.cpp	(revision 89c6bb163eac63d5688106f7b34d9ede49b914e5)
+++ b/Telegram/SourceFiles/data/data_peer.cpp	(date 1711436159065)
@@ -1074,14 +1074,7 @@
 }
 
 bool PeerData::allowsForwarding() const {
-	if (const auto user = asUser()) {
-		return true;
-	} else if (const auto channel = asChannel()) {
-		return channel->allowsForwarding();
-	} else if (const auto chat = asChat()) {
-		return chat->allowsForwarding();
-	}
-	return false;
+	return true;
 }
 
 Data::RestrictionCheckResult PeerData::amRestricted(
Index: Telegram/SourceFiles/data/data_chat.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/Telegram/SourceFiles/data/data_chat.cpp b/Telegram/SourceFiles/data/data_chat.cpp
--- a/Telegram/SourceFiles/data/data_chat.cpp	(revision 89c6bb163eac63d5688106f7b34d9ede49b914e5)
+++ b/Telegram/SourceFiles/data/data_chat.cpp	(date 1711436207659)
@@ -64,7 +64,7 @@
 }
 
 bool ChatData::allowsForwarding() const {
-	return !(flags() & Flag::NoForwards);
+	return true;
 }
 
 bool ChatData::canEditInformation() const {
Index: Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp b/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp
--- a/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp	(revision 89c6bb163eac63d5688106f7b34d9ede49b914e5)
+++ b/Telegram/SourceFiles/media/view/media_view_overlay_widget.cpp	(date 1711424890852)
@@ -1037,13 +1037,7 @@
 }
 
 bool OverlayWidget::hasCopyMediaRestriction(bool skipPremiumCheck) const {
-	if (const auto story = _stories ? _stories->story() : nullptr) {
-		return skipPremiumCheck
-			? !story->canDownloadIfPremium()
-			: !story->canDownloadChecked();
-	}
-	return (_history && !_history->peer->allowsForwarding())
-		|| (_message && _message->forbidsSaving());
+	return false;
 }
 
 bool OverlayWidget::showCopyMediaRestriction(bool skipPRemiumCheck) {
Index: Telegram/SourceFiles/history/history_inner_widget.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp
--- a/Telegram/SourceFiles/history/history_inner_widget.cpp	(revision 89c6bb163eac63d5688106f7b34d9ede49b914e5)
+++ b/Telegram/SourceFiles/history/history_inner_widget.cpp	(date 1711424890841)
@@ -2760,7 +2760,7 @@
 
 bool HistoryInner::hasCopyMediaRestriction(
 		not_null<HistoryItem*> item) const {
-	return hasCopyRestriction(item) || item->forbidsSaving();
+	return false;
 }
 
 bool HistoryInner::showCopyRestriction(HistoryItem *item) {

Windows编译环境和注意事项

  • 尽可能不要使用GUI IDE来编译,比如使用在native控制台窗口中的的devenv telegram.sln /Build "Release|x64" /Project Telegram命令来编译最终的可执行文件而不是使用Visual Studio打开sln项目去点击菜单中的build按钮,似乎GUI环境会做出一些莫名其妙的改变导致代码出问题。
  • 如果需要修改代码,不要使用VS,而使用JetBrains的工具或者任何其他不会有副作用的编辑器来编辑代码。
  • 编译环境直接搜索下载"Windows Dev VMware Image",使用这个虚拟开发环境是最方便的,从而避免系统没有调整成unicode或者区域设置等问题,也避免visual studio安装麻烦,不过注意虚拟环境中似乎缺少C++抽象模板库,需要在Visual Studio Installer中修改,在individual component一栏中找到对应版本的ATL安装,否则可能会有依赖库在前期创建项目的时候无法编译。
  • 代理设置,要配置好控制台和git的代理,并且代理规则至少是绕过大陆模式,而不是黑名单模式。
  • 内存问题,编译MinSizeRel需要比较多的内存,虚拟机8GB编译失败,但是能编译普通的Release。尝试将虚拟机的内存调大到13.4GB(宿主机16GB内存的情况下推荐的最大内存)能够编译MinSizeRel。

Mac编译环境和注意事项

Mac上比较简单,因为brew能够帮很多忙,保证网络良好的情况下官方的命令已经比较简单了,但是有以下几个麻烦。

  • 代理设置,要配置好控制台和git的代理,并且代理规则至少是绕过大陆模式,而不是黑名单模式。
  • 在arm架构的Mac上编译libde265的时候因为第三方库没有找到x86版本而不能编译,去prepare.py中寻找相关命令,去掉“X86_64;arm64”中的X86_64即可,如果遇到问题,参考prepare.py手动运行命令尝试解决。
  • 不要使用XCode编辑代码,最好根本不要使用XCode的GUI打开项目,可能会出错。
  • 不要使用Mac上的XCode GUI编译,而是在有xcodeproject文件的目录中使用命令 xcodebuild -scheme Telegram build -configuration MinSizeRel来编译,似乎XCode的GUI也会导致一些问题。