snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
89
tools/screenshots/companies.json
Normal file
@@ -0,0 +1,89 @@
|
||||
[
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "general",
|
||||
"color": "#94C849",
|
||||
"topic": "Zulip features",
|
||||
"users": {
|
||||
"Bo Williams": {},
|
||||
"Ariella Drake": {},
|
||||
"Kevin Lin": {},
|
||||
"Nolan Turner": {},
|
||||
"Anna Smith": {},
|
||||
"Zoe Davis": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Bo Williams",
|
||||
"content": "When you hover over a Python code block, you'll now see a button that opens the code in a playground. Try it out: :playground_slide:\n\n```python\nprint(\"Hello world!\")\n```",
|
||||
"reactions": {
|
||||
"tada": [
|
||||
"Zoe Davis",
|
||||
"Nolan Turner",
|
||||
"Anna Smith",
|
||||
"Ariella Drake",
|
||||
"Kevin Lin"
|
||||
]
|
||||
},
|
||||
"date": {"year": 2024, "month": 5, "day": 16, "hour": 13, "minute": 59}
|
||||
},
|
||||
{
|
||||
"sender": "Ariella Drake",
|
||||
"content": "LaTeX math blocks are nice — I wish I had them in grad school. :wink:",
|
||||
"date": {"year": 2024, "month": 5, "day": 16, "hour": 14, "minute": 27}
|
||||
},
|
||||
{
|
||||
"sender": "Ariella Drake",
|
||||
"content": "```math\n\\phi = 1 + \\cfrac{1}{1+\\cfrac{1}{1+\\cdots}}\n```",
|
||||
"date": {"year": 2024, "month": 5, "day": 16, "hour": 14, "minute": 30}
|
||||
},
|
||||
{
|
||||
"sender": "Kevin Lin",
|
||||
"content": "Interesting! https://www.youtube.com/watch?v=cbj59mVwErg",
|
||||
"date": {"year": 2024, "month": 5, "day": 16, "hour": 15, "minute": 5}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/companies/message-formatting-top.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "general",
|
||||
"color": "#9987E1",
|
||||
"topic": "presentation feedback",
|
||||
"users": {
|
||||
"Maxy Stert": {},
|
||||
"Kevin Lin": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Maxy Stert",
|
||||
"content": "Could someone take a look at my presentation draft? [presentation](#)",
|
||||
"date": {"year": 2024, "month": 5, "day": 17, "hour": 16, "minute": 35}
|
||||
},
|
||||
{
|
||||
"sender": "Kevin Lin",
|
||||
"content": "Sure! Let’s do a call at <time:2024-05-17T14:30:00-07:00> to go through it.",
|
||||
"reactions": {
|
||||
"+1": ["Maxy Stert"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 5, "day": 17, "hour": 17, "minute": 37}
|
||||
},
|
||||
{
|
||||
"sender": "Maxy Stert",
|
||||
"content": "[Join video call.](#)",
|
||||
"date": {"year": 2024, "month": 5, "day": 17, "hour": 21, "minute": 30}
|
||||
},
|
||||
{
|
||||
"sender": "Maxy Stert",
|
||||
"content": "I got great feedback from @_**Kevin Lin**",
|
||||
"date": {"year": 2024, "month": 5, "day": 17, "hour": 22, "minute": 7}
|
||||
},
|
||||
{
|
||||
"sender": "Maxy Stert",
|
||||
"content": "/poll When we should do a dry run?\nExtend tomorrow's meeting\nNext week",
|
||||
"date": {"year": 2024, "month": 5, "day": 17, "hour": 22, "minute": 15}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/companies/message-formatting-bottom.png"
|
||||
}
|
||||
]
|
||||
136
tools/screenshots/for-education.json
Normal file
@@ -0,0 +1,136 @@
|
||||
[
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "Lecture 3 Heaps",
|
||||
"color": "#A6DCBF",
|
||||
"topic": "✔ problem 2a clarification",
|
||||
"users": {
|
||||
"John Lin": {},
|
||||
"Dal Kim": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "John Lin",
|
||||
"content": "I'm confused -- why does the code for this one say: \n\n```python\nint my_array[] = {0, 1, 2, 3}\n```\nBut in 2b it's:\n\n```python\nint my_array[4] = {0, 1, 2, 3}\n```\nis it a typo?",
|
||||
"edited": true,
|
||||
"date": {"year": 2025, "month": 6, "day": 19, "hour": 18, "minute": 0}
|
||||
},
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "Nope, both are correct! Take a look at slide 17 in the lecture notes.",
|
||||
"reactions": {
|
||||
"+1": ["John Lin"]
|
||||
},
|
||||
"date": {"year": 2025, "month": 6, "day": 19, "hour": 18, "minute": 37}
|
||||
},
|
||||
{
|
||||
"sender": "Notification Bot",
|
||||
"content": "@_**Dal Kim** has marked this topic as resolved.",
|
||||
"date": {"year": 2025, "month": 6, "day": 19, "hour": 19, "minute": 0}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/education/message_formatting_day_1.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "Lecture 3 Heaps",
|
||||
"color": "#A6DCBF",
|
||||
"topic": "lecture notes and recording",
|
||||
"users": {
|
||||
"Elena García": {},
|
||||
"Dal Kim": {},
|
||||
"John Lin": {},
|
||||
"Maxy Stert": {},
|
||||
"Kevin Lin": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Elena García",
|
||||
"content": "Here are the notes for today's class: [lecture3.pdf](#). You can also [watch the recording](#) if you missed it.",
|
||||
"reactions": {
|
||||
"+1": ["Dal Kim", "John Lin", "Maxy Stert", "Kevin Lin"]
|
||||
},
|
||||
"starred": true,
|
||||
"date": {"year": 2025, "month": 5, "day": 21, "hour": 11, "minute": 3}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/education/interactive_messaging_day_1.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "staff",
|
||||
"invite_only": true,
|
||||
"color": "#E4523D",
|
||||
"topic": "assignment #3 solutions",
|
||||
"users": {
|
||||
"Dal Kim": {},
|
||||
"Zoe Davis": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "Can someone double-check my solution for problem 4? The run time is $$O(\\log n)$$, right?",
|
||||
"date": {"year": 2025, "month": 5, "day": 19, "hour": 14, "minute": 40}
|
||||
},
|
||||
{
|
||||
"sender": "Zoe Davis",
|
||||
"content": "Yep, that's the amortized time, but the upper bound of it is $$O(2^{2\\sqrt{\\log \\log n}})$$.",
|
||||
"reactions": {
|
||||
"+1": ["Iago"]
|
||||
},
|
||||
"date": {"year": 2025, "month": 5, "day": 19, "hour": 15, "minute": 43}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/education/message_formatting_day_2.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "announcements",
|
||||
"color": "#A6C7E5",
|
||||
"topic": "homework reminders",
|
||||
"users": {
|
||||
"Elena García": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Elena García",
|
||||
"content": "@**everyone** Please remember to submit your assignments before <time:2025-05-12T00:00:00+05:30>! You will lose **at least 25%** of the credit if your assignment is late.",
|
||||
"date": {"year": 2025, "month": 5, "day": 21, "hour": 13, "minute": 10}
|
||||
},
|
||||
{
|
||||
"sender": "Elena García",
|
||||
"content": "It sounds like many of you are stuck at the bonus problem, so here's a hint. Please think about the question first before you view it\n\n```spoiler Hint for bonus problem\nHint\n```",
|
||||
"date": {"year": 2025, "month": 5, "day": 21, "hour": 14, "minute": 11}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/education/interactive_messaging_day_2.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "announcements",
|
||||
"color": "#A6C7E5",
|
||||
"topic": "rescheduling office hours",
|
||||
"user_groups": [
|
||||
{
|
||||
"group_name": "section 1",
|
||||
"members": ["Dal Kim"]
|
||||
}
|
||||
],
|
||||
"users": {
|
||||
"Dal Kim": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "@*section 1* I need to reschedule my office hours this week. Which time works better?",
|
||||
"date": {"year": 2025, "month": 5, "day": 20, "hour": 15, "minute": 15}
|
||||
},
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "/poll Section 1 office hours\nThursday 1-2 PM\nFriday 10-11 AM",
|
||||
"date": {"year": 2025, "month": 5, "day": 20, "hour": 15, "minute": 20}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/education/interactive_messaging_day_3.png"
|
||||
}
|
||||
]
|
||||
84
tools/screenshots/for-events.json
Normal file
@@ -0,0 +1,84 @@
|
||||
[
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "Session 2 Keynote",
|
||||
"color": "#C6A8AD",
|
||||
"topic": "slides and presentation",
|
||||
"users": {
|
||||
"Dal Kim": {},
|
||||
"Kevin Lin": {},
|
||||
"John Lin": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "Welcome everyone! Here are the slides for the keynote talk: [keynote-slides.ppt](#). Don't forget to join for a live presentation at <time:2024-05-22T17:00:00+05:30>!",
|
||||
"starred": true,
|
||||
"reactions": {
|
||||
"+1": ["Iago", "Kevin Lin", "John Lin"],
|
||||
"tada": ["John Lin", "Kevin Lin"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 5, "day": 21, "hour": 13, "minute": 45}
|
||||
},
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "Join now for the Keynote presentation!\nWe'll be using this stream for Q&A.\n\n[Join video call.](#)",
|
||||
"date": {"year": 2024, "month": 5, "day": 21, "hour": 17, "minute": 0}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/events/your_communication_hub_day_1.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "general",
|
||||
"color": "#9987E1",
|
||||
"topic": "conference feedback",
|
||||
"users": {
|
||||
"James Williams": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "James Williams",
|
||||
"content": "As we are wrapping up, let us know what you enjoyed the most about this conference.",
|
||||
"date": {"year": 2024, "month": 5, "day": 21, "hour": 20, "minute": 50}
|
||||
},
|
||||
{
|
||||
"sender": "James Williams",
|
||||
"content": "/poll What did you like the best?\nKeynote address\nLightning talks\nUsing Zulip",
|
||||
"date": {"year": 2024, "month": 5, "day": 21, "hour": 20, "minute": 55}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/events/your_communication_hub_day_2.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "Day 2: Intro to RSA",
|
||||
"color": "#94C849",
|
||||
"topic": "factorization algorithms",
|
||||
"users": {
|
||||
"Manvir Singh": {},
|
||||
"Zoe Davis": {},
|
||||
"Maxy Stert": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Manvir Singh",
|
||||
"content": "You could use [Pollard's rho algorithm](#), which factors a number n in time $$O(n^{\\frac{1}{4}})$$, faster than trial division:\n\n```python\ndef pollard_rho(n: int) -> int:\n g = lambda x: (x ** 2 + 1) % n\n x, y, d = 2, 2, 1\n while d == 1:\n x, y = g(x), g(g(y))\n d = math.gcd(x - y, n)\n return d\n```",
|
||||
"reactions": {
|
||||
"+1": ["Zoe Davis"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 5, "day": 14, "hour": 16, "minute": 59}
|
||||
},
|
||||
{
|
||||
"sender": "Zoe Davis",
|
||||
"content": "The fastest known algorithm is the general number field sieve, which heuristically runs in time:\n```math\n\\exp\\left(\\left(\\sqrt[3]{\\frac{64}{9}} + o(1)\\right)(\\ln n)^{\\frac{1}{3}} (\\ln\\ln n)^{\\frac{2}{3}}\\right)\n```\n However, it is much more complicated.",
|
||||
"reactions": {
|
||||
"runner": ["Manvir Singh"],
|
||||
"thinking": ["Maxy Stert", "Iago"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 5, "day": 14, "hour": 18, "minute": 19}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/events/message_formatting_day.png"
|
||||
}
|
||||
]
|
||||
67
tools/screenshots/for-research.json
Normal file
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "general",
|
||||
"color": "#9987E1",
|
||||
"topic": "paper posted",
|
||||
"users": {
|
||||
"John Lin": {},
|
||||
"Dal Kim": {},
|
||||
"Bo Lin": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "John Lin",
|
||||
"content": "Hi all! We just posted a pre-publication draft of our paper! Sharing here for feedback, suggestions, etc. [paper.pdf](#)",
|
||||
"reactions": {
|
||||
"tada": ["John Lin", "Iago", "Dal Kim", "Bo Lin"],
|
||||
"eyes": ["Dal Kim", "Iago"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 6, "day": 4, "hour": 14, "minute": 39}
|
||||
},
|
||||
{
|
||||
"sender": "Dal Kim",
|
||||
"content": "Just noticed a small typo in Figure 4. The caption says `detales` instead of `details`. :smile:",
|
||||
"reactions": {
|
||||
"check": ["John Lin"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 6, "day": 4, "hour": 14, "minute": 57}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/research/interactive_messaging_day_1.png"
|
||||
},
|
||||
{
|
||||
"recipient_type": "channel",
|
||||
"channel": "thesis support group",
|
||||
"color": "#95A5FD",
|
||||
"topic": "chat live",
|
||||
"users": {
|
||||
"Zoe Davis": {},
|
||||
"James Williams": {},
|
||||
"Bo Lin": {},
|
||||
"Nolan Turner": {}
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"sender": "Zoe Davis",
|
||||
"content": "Hey! How does <time:2024-05-14T17:00:00+05:30> sound for doing a video call today? My paper just got rejected.",
|
||||
"reactions": {
|
||||
"+1": ["James Williams", "Iago", "Zoe Davis", "Bo Lin", "Nolan Turner"],
|
||||
"broken_heart": ["James Williams", "Iago", "Zoe Davis", "Nolan Turner"]
|
||||
},
|
||||
"date": {"year": 2024, "month": 5, "day": 13, "hour": 15, "minute": 15}
|
||||
},
|
||||
{
|
||||
"sender": "James Williams",
|
||||
"content": "I need to send off a draft of chapter 2 to my advisor. Will try to make it!",
|
||||
"date": {"year": 2024, "month": 5, "day": 13, "hour": 15, "minute": 34}
|
||||
},
|
||||
{
|
||||
"sender": "Zoe Davis",
|
||||
"content": "@**everyone** Video call is happening now! [Join video call.](#)",
|
||||
"date": {"year": 2024, "month": 5, "day": 13, "hour": 16, "minute": 30}
|
||||
}
|
||||
],
|
||||
"screenshot": "static/images/landing-page/research/interactive_messaging_day_2.png"
|
||||
}
|
||||
]
|
||||
513
tools/screenshots/generate-integration-docs-screenshot
Executable file
@@ -0,0 +1,513 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or update a webhook integration screenshot using a test fixture."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import zulip
|
||||
|
||||
SCREENSHOTS_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
TOOLS_DIR = os.path.abspath(os.path.dirname(SCREENSHOTS_DIR))
|
||||
ROOT_DIR = os.path.dirname(TOOLS_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
# check for the venv
|
||||
from tools.lib import sanity_check
|
||||
|
||||
sanity_check.check_venv(__file__)
|
||||
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
|
||||
from scripts.lib.zulip_tools import BOLDRED, ENDC
|
||||
from tools.lib.test_script import prepare_puppeteer_run
|
||||
from zerver.actions.create_user import do_create_user, notify_created_bot
|
||||
from zerver.actions.message_send import do_send_messages, internal_prep_stream_message
|
||||
from zerver.actions.streams import bulk_add_subscriptions
|
||||
from zerver.actions.user_settings import do_change_avatar_fields
|
||||
from zerver.lib.integrations import (
|
||||
FIXTURELESS_INTEGRATIONS_WITH_SCREENSHOTS,
|
||||
INCOMING_WEBHOOK_INTEGRATIONS,
|
||||
INTEGRATIONS,
|
||||
BotIntegration,
|
||||
FixturelessScreenshotConfig,
|
||||
IncomingWebhookIntegration,
|
||||
Integration,
|
||||
WebhookScreenshotConfig,
|
||||
get_fixture_path,
|
||||
get_image_path,
|
||||
split_fixture_path,
|
||||
)
|
||||
from zerver.lib.storage import static_path
|
||||
from zerver.lib.streams import create_stream_if_needed, ensure_stream
|
||||
from zerver.lib.upload import upload_avatar_image
|
||||
from zerver.lib.url_encoding import topic_narrow_url
|
||||
from zerver.lib.webhooks.common import call_fixture_to_headers, parse_multipart_string
|
||||
from zerver.models import Message, Stream, UserProfile
|
||||
from zerver.models.realms import Realm, get_realm
|
||||
from zerver.models.users import get_user_by_delivery_email
|
||||
from zerver.webhooks.fixtureless_integrations import MessageThread
|
||||
|
||||
|
||||
def create_integration_bot(integration: Integration, bot_name: str | None = None) -> UserProfile:
|
||||
realm = get_realm("zulip")
|
||||
owner = get_user_by_delivery_email("iago@zulip.com", realm)
|
||||
email_prefix = (
|
||||
re.sub(r"[^a-zA-Z0-9_-]", "", bot_name.strip()).lower()[:64]
|
||||
if bot_name
|
||||
else integration.name
|
||||
)
|
||||
bot_email = f"{email_prefix}-bot@example.com"
|
||||
if bot_name is None:
|
||||
if isinstance(integration, BotIntegration):
|
||||
bot_name = integration.display_name
|
||||
else:
|
||||
bot_name = f"{integration.display_name} Bot"
|
||||
try:
|
||||
bot = UserProfile.objects.get(email=bot_email)
|
||||
except UserProfile.DoesNotExist:
|
||||
bot = do_create_user(
|
||||
email=bot_email,
|
||||
password="123",
|
||||
realm=owner.realm,
|
||||
full_name=bot_name,
|
||||
bot_type=UserProfile.INCOMING_WEBHOOK_BOT,
|
||||
bot_owner=owner,
|
||||
acting_user=owner,
|
||||
)
|
||||
notify_created_bot(bot)
|
||||
|
||||
bot_avatar_path = integration.get_bot_avatar_path()
|
||||
bot_avatar_path = static_path(bot_avatar_path)
|
||||
if os.path.isfile(bot_avatar_path):
|
||||
with open(bot_avatar_path, "rb") as f:
|
||||
upload_avatar_image(f, bot, content_type="image/png")
|
||||
do_change_avatar_fields(bot, UserProfile.AVATAR_FROM_USER, acting_user=owner)
|
||||
else:
|
||||
raise ValueError(f"Bot avatar file {bot_avatar_path} does not exist.")
|
||||
|
||||
return bot
|
||||
|
||||
|
||||
def create_integration_channel(channel_name: str, bot: UserProfile) -> None:
|
||||
assert isinstance(bot.bot_owner, UserProfile)
|
||||
realm = bot.bot_owner.realm
|
||||
channel, _created = create_stream_if_needed(realm, channel_name)
|
||||
bulk_add_subscriptions(realm, [channel], [bot, bot.bot_owner], acting_user=bot)
|
||||
|
||||
|
||||
def create_user(
|
||||
realm: Realm, username: str, acting_user: UserProfile, channel: Stream
|
||||
) -> UserProfile:
|
||||
try:
|
||||
user = UserProfile.objects.get(realm=realm, full_name=username)
|
||||
except UserProfile.DoesNotExist:
|
||||
sender_email = f"{username.replace(' ', '').lower()}@example.com"
|
||||
user = do_create_user(
|
||||
email=sender_email,
|
||||
password="123",
|
||||
realm=realm,
|
||||
full_name=username,
|
||||
acting_user=acting_user,
|
||||
)
|
||||
|
||||
bulk_add_subscriptions(realm, [channel], [user], acting_user=acting_user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_fixture_info(fixture_path: str) -> tuple[Any, bool, bool, str]:
|
||||
json_fixture = fixture_path.endswith(".json")
|
||||
multipart_fixture = fixture_path.endswith(".multipart")
|
||||
_, fixture_name = split_fixture_path(fixture_path)
|
||||
|
||||
if fixture_name:
|
||||
if not os.path.exists(fixture_path):
|
||||
raise FileNotFoundError(
|
||||
f"{BOLDRED}The fixture '{fixture_path}' does not exist. Please check the fixture file name and try again.{ENDC}"
|
||||
)
|
||||
if json_fixture:
|
||||
with open(fixture_path, "rb") as fb:
|
||||
data = orjson.loads(fb.read())
|
||||
else:
|
||||
with open(fixture_path) as f:
|
||||
data = f.read().strip()
|
||||
if multipart_fixture:
|
||||
data = parse_multipart_string(data)
|
||||
else:
|
||||
data = ""
|
||||
|
||||
return data, json_fixture, multipart_fixture, fixture_name
|
||||
|
||||
|
||||
def get_requests_headers(integration_dir_name: str, fixture_name: str) -> dict[str, Any]:
|
||||
headers = call_fixture_to_headers(integration_dir_name, fixture_name)
|
||||
|
||||
def fix_name(header: str) -> str:
|
||||
return header.removeprefix("HTTP_").replace("_", "-")
|
||||
|
||||
return {fix_name(k): v for k, v in headers.items()}
|
||||
|
||||
|
||||
def custom_headers(headers_json: str) -> dict[str, str]:
|
||||
if not headers_json:
|
||||
return {}
|
||||
try:
|
||||
return orjson.loads(headers_json)
|
||||
except orjson.JSONDecodeError as ve:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Encountered an error while attempting to parse custom headers: {ve}\n"
|
||||
"Note: all strings must be enclosed within \"\" instead of ''"
|
||||
)
|
||||
|
||||
|
||||
def send_bot_payload_message(
|
||||
bot: UserProfile,
|
||||
integration: IncomingWebhookIntegration,
|
||||
fixture_path: str,
|
||||
config: WebhookScreenshotConfig,
|
||||
) -> bool:
|
||||
# Delete all messages, so new message is the only one it's message group
|
||||
Message.objects.filter(realm_id=bot.realm_id, sender=bot).delete()
|
||||
data, json_fixture, multipart_fixture, fixture_name = get_fixture_info(fixture_path)
|
||||
|
||||
headers = get_requests_headers(integration.dir_name, fixture_name)
|
||||
if config.custom_headers:
|
||||
headers.update(config.custom_headers)
|
||||
if config.use_basic_auth:
|
||||
credentials = base64.b64encode(f"{bot.email}:{bot.api_key}".encode()).decode()
|
||||
auth = f"basic {credentials}"
|
||||
headers.update(dict(Authorization=auth))
|
||||
|
||||
assert isinstance(bot.bot_owner, UserProfile)
|
||||
channel_name = config.channel or integration.name
|
||||
url = f"{bot.bot_owner.realm.url}/{integration.url}"
|
||||
params = {"api_key": bot.api_key, "stream": channel_name}
|
||||
params.update(config.extra_params)
|
||||
|
||||
extra_args = {}
|
||||
if multipart_fixture:
|
||||
extra_args = {"data": data}
|
||||
|
||||
elif not json_fixture and data:
|
||||
assert isinstance(data, str)
|
||||
|
||||
# fixtures with url parameters
|
||||
if "&" in data:
|
||||
# Overwrite the fixture params, in case of overlap.
|
||||
parsed_params = dict(parse_qsl(data))
|
||||
parsed_params.update(params)
|
||||
params = parsed_params
|
||||
|
||||
# fixtures with plain/text payload
|
||||
else:
|
||||
extra_args = {"data": data}
|
||||
|
||||
elif config.payload_as_query_param:
|
||||
params[config.payload_param_name] = orjson.dumps(data).decode()
|
||||
|
||||
else:
|
||||
extra_args = {"json": data}
|
||||
|
||||
url = f"{url}?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
response = requests.post(url=url, headers=headers, **extra_args)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(
|
||||
"This tool needs the local dev server to be running. "
|
||||
"Please start it using tools/run-dev before running this tool."
|
||||
)
|
||||
sys.exit(1)
|
||||
if response.status_code != 200:
|
||||
print(response.json())
|
||||
print("Failed to trigger webhook")
|
||||
return False
|
||||
else:
|
||||
print(f"Triggered {integration.name} webhook")
|
||||
return True
|
||||
|
||||
|
||||
def send_bot_mock_message(bot: UserProfile, channel: str, topic: str, message: str) -> None:
|
||||
# Delete all messages, so that the new message is the only one in its message group
|
||||
Message.objects.filter(realm_id=bot.realm_id, sender=bot).delete()
|
||||
assert bot.bot_owner is not None
|
||||
url = f"{bot.bot_owner.realm.url}"
|
||||
client = zulip.Client(email=bot.email, api_key=bot.api_key, site=url)
|
||||
|
||||
request = {"type": "stream", "to": channel, "topic": topic, "content": message}
|
||||
client.send_message(request)
|
||||
|
||||
|
||||
def capture_last_message_screenshot(bot: UserProfile, image_path: str) -> None:
|
||||
message = Message.objects.filter(realm_id=bot.realm_id, sender=bot).last()
|
||||
realm = get_realm("zulip")
|
||||
if message is None:
|
||||
print(f"No message found for {bot.full_name}")
|
||||
return
|
||||
message_id = str(message.id)
|
||||
screenshot_script = os.path.join(SCREENSHOTS_DIR, "message-screenshot.ts")
|
||||
subprocess.check_call(["node", screenshot_script, message_id, image_path, realm.url])
|
||||
|
||||
|
||||
def send_thread_messages(
|
||||
bot: UserProfile,
|
||||
channel_name: str,
|
||||
topic: str,
|
||||
thread_messages_data: list[MessageThread],
|
||||
integration: Integration,
|
||||
) -> list[int]:
|
||||
realm = bot.realm
|
||||
channel = ensure_stream(realm, channel_name, acting_user=bot.bot_owner)
|
||||
assert bot.bot_owner is not None
|
||||
user = create_user(
|
||||
realm=realm, username="Bo Williams", acting_user=bot.bot_owner, channel=channel
|
||||
)
|
||||
|
||||
Message.objects.filter(
|
||||
realm=realm,
|
||||
recipient=channel.recipient,
|
||||
subject__iexact=topic,
|
||||
).delete()
|
||||
|
||||
message_requests = []
|
||||
for messages in thread_messages_data:
|
||||
sender_name = messages["sender"]
|
||||
content = messages["message"]
|
||||
|
||||
if sender_name in {bot.full_name, integration.display_name, integration.name.title()}:
|
||||
sender = bot
|
||||
else:
|
||||
sender = user
|
||||
|
||||
message_request = internal_prep_stream_message(
|
||||
sender,
|
||||
channel,
|
||||
topic,
|
||||
content,
|
||||
)
|
||||
message_requests.append(message_request)
|
||||
|
||||
sent_message_results = do_send_messages(message_requests)
|
||||
return [result.message_id for result in sent_message_results]
|
||||
|
||||
|
||||
def capture_thread_screenshot(
|
||||
bot: UserProfile, channel_name: str, topic: str, message_ids: list[int], image_path: str
|
||||
) -> None:
|
||||
assert bot.bot_owner is not None
|
||||
stream = ensure_stream(bot.realm, channel_name, acting_user=bot.bot_owner)
|
||||
narrow_uri = topic_narrow_url(realm=bot.realm, stream=stream, topic_name=topic)
|
||||
narrow = f"{channel_name}/{topic}"
|
||||
screenshot_script = os.path.join(SCREENSHOTS_DIR, "thread-screenshot.ts")
|
||||
subprocess.check_call(
|
||||
[
|
||||
"node",
|
||||
screenshot_script,
|
||||
narrow_uri,
|
||||
narrow,
|
||||
str(min(message_ids)),
|
||||
image_path,
|
||||
bot.realm.url,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def generate_screenshots_for_integration(integration: Integration) -> None:
|
||||
if integration.screenshot_configs is not None:
|
||||
for screenshot_config in integration.screenshot_configs:
|
||||
generate_screenshot_from_config(integration.name, screenshot_config)
|
||||
|
||||
|
||||
def generate_screenshot_from_config(
|
||||
integration_name: str, screenshot_config: WebhookScreenshotConfig | FixturelessScreenshotConfig
|
||||
) -> None:
|
||||
integration = INTEGRATIONS[integration_name]
|
||||
bot = create_integration_bot(integration, screenshot_config.bot_name)
|
||||
channel_name = screenshot_config.channel or integration.name
|
||||
create_integration_channel(channel_name, bot)
|
||||
image_path = get_image_path(integration, screenshot_config)
|
||||
match (integration, screenshot_config):
|
||||
case (IncomingWebhookIntegration(), WebhookScreenshotConfig()):
|
||||
fixture_path = get_fixture_path(integration, screenshot_config)
|
||||
if send_bot_payload_message(bot, integration, fixture_path, screenshot_config):
|
||||
capture_last_message_screenshot(bot, image_path)
|
||||
case (_, FixturelessScreenshotConfig(message=messages)) if isinstance(messages, list):
|
||||
message_ids = send_thread_messages(
|
||||
bot, channel_name, screenshot_config.topic, messages, integration
|
||||
)
|
||||
if message_ids:
|
||||
capture_thread_screenshot(
|
||||
bot, channel_name, screenshot_config.topic, message_ids, image_path
|
||||
)
|
||||
case (_, FixturelessScreenshotConfig(message=message)) if isinstance(message, str):
|
||||
send_bot_mock_message(
|
||||
bot,
|
||||
channel=channel_name,
|
||||
topic=screenshot_config.topic,
|
||||
message=message,
|
||||
)
|
||||
capture_last_message_screenshot(bot, image_path)
|
||||
case _:
|
||||
raise ValueError(f"Invalid configuration for integration {integration.name}")
|
||||
if os.path.exists(image_path):
|
||||
print(f"Screenshot captured to: {BOLDRED}{image_path}{ENDC}")
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
batch_group = parser.add_mutually_exclusive_group(required=True)
|
||||
batch_group.add_argument("--all", action="store_true")
|
||||
batch_group.add_argument("--all-webhook", action="store_true")
|
||||
batch_group.add_argument("--all-fixtureless", action="store_true")
|
||||
batch_group.add_argument(
|
||||
"--skip-until",
|
||||
help="Name of the integration to start processing from, including all that follow in alphabetical order. Similar to --all",
|
||||
)
|
||||
batch_group.add_argument("--integration", nargs="+", help="Names of specific integrations")
|
||||
|
||||
common_group = parser.add_argument_group()
|
||||
common_group.add_argument("--image-name", help="Name for the screenshot image")
|
||||
common_group.add_argument("--image-dir", help="Directory name where to save the screenshot image")
|
||||
common_group.add_argument("--bot-name", help="Name to use for the bot")
|
||||
|
||||
webhook_group = parser.add_argument_group("Webhook integrations options")
|
||||
webhook_group.add_argument(
|
||||
"--fixture-name",
|
||||
help="Name of the fixture file to use. All other webhook options are ignored if the fixture is not specified.",
|
||||
)
|
||||
webhook_group.add_argument(
|
||||
"-A", "--use-basic-auth", action="store_true", help="Add basic auth headers to the request"
|
||||
)
|
||||
webhook_group.add_argument(
|
||||
"-Q",
|
||||
"--payload-as-query-param",
|
||||
action="store_true",
|
||||
help="Send payload as query param instead of body",
|
||||
)
|
||||
webhook_group.add_argument("-P", "--payload-param-name", help="Param name to use for the payload")
|
||||
webhook_group.add_argument(
|
||||
"-H",
|
||||
"--custom-headers",
|
||||
type=custom_headers,
|
||||
help="Any additional headers to be sent with the request.",
|
||||
)
|
||||
|
||||
fixtureless_group = parser.add_argument_group("Fixtureless non-webhook integrations options")
|
||||
fixtureless_group.add_argument("-T", "--topic", help="Topic to use for the mock message")
|
||||
fixtureless_group.add_argument("-M", "--message", help="Message to use for the mock message")
|
||||
fixtureless_group.add_argument(
|
||||
"-C",
|
||||
"--channel",
|
||||
help="Channel name to use for the mock message. Ignored if --topic and --message are not specified.",
|
||||
)
|
||||
|
||||
options = parser.parse_args()
|
||||
prepare_puppeteer_run()
|
||||
|
||||
|
||||
def validate_integration_count(
|
||||
options: argparse.Namespace, parser: argparse.ArgumentParser, option_name: str
|
||||
) -> None:
|
||||
if len(options.integration) != 1:
|
||||
parser.error(
|
||||
f"Exactly one integration should be specified with --integration when --{option_name} is provided"
|
||||
)
|
||||
|
||||
|
||||
def build_config(options: argparse.Namespace, config_keys: list[str]) -> dict[str, Any]:
|
||||
config = {
|
||||
key: getattr(options, key) for key in config_keys if getattr(options, key) is not None
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
def generate_full_batch_screenshots(
|
||||
option_key: str,
|
||||
integration_list: list[Integration] | list[IncomingWebhookIntegration],
|
||||
batch_type: str,
|
||||
) -> None:
|
||||
for key, value in vars(options).items():
|
||||
if key != option_key and value:
|
||||
print(f"Generating screenshots for {batch_type}. Ignoring all command-line options")
|
||||
break
|
||||
for integration in integration_list:
|
||||
generate_screenshots_for_integration(integration)
|
||||
|
||||
|
||||
if options.all:
|
||||
generate_full_batch_screenshots("all", list(INTEGRATIONS.values()), "all integrations")
|
||||
elif options.all_webhook:
|
||||
generate_full_batch_screenshots(
|
||||
"all_webhook", INCOMING_WEBHOOK_INTEGRATIONS, "all incoming webhook integrations"
|
||||
)
|
||||
elif options.all_fixtureless:
|
||||
fixtureless_integrations_list = [
|
||||
INTEGRATIONS[name] for name in FIXTURELESS_INTEGRATIONS_WITH_SCREENSHOTS
|
||||
]
|
||||
generate_full_batch_screenshots(
|
||||
"all_fixtureless", fixtureless_integrations_list, "all fixtureless integrations"
|
||||
)
|
||||
elif options.skip_until:
|
||||
for key, value in vars(options).items():
|
||||
if key != "skip_until" and value:
|
||||
print(
|
||||
f"Generating screenshots for all integrations starting from {options.skip_until}. Ignoring all command-line options"
|
||||
)
|
||||
break
|
||||
skip = True
|
||||
for integration_name in sorted(INTEGRATIONS):
|
||||
if integration_name == options.skip_until:
|
||||
skip = False
|
||||
if skip:
|
||||
continue
|
||||
generate_screenshots_for_integration(INTEGRATIONS[integration_name])
|
||||
|
||||
elif options.fixture_name:
|
||||
validate_integration_count(options, parser, "fixture-name")
|
||||
webhook_options = [action.dest for action in webhook_group._group_actions]
|
||||
common_options = [action.dest for action in common_group._group_actions]
|
||||
config = build_config(options, webhook_options + common_options)
|
||||
webhook_screenshot_config = WebhookScreenshotConfig(**config)
|
||||
generate_screenshot_from_config(options.integration[0], webhook_screenshot_config)
|
||||
|
||||
elif options.topic or options.message:
|
||||
if not options.topic or not options.message:
|
||||
parser.error("Both --topic and --message must be specified together")
|
||||
validate_integration_count(options, parser, "topic")
|
||||
fixtureless_options = [action.dest for action in fixtureless_group._group_actions]
|
||||
common_options = [action.dest for action in common_group._group_actions]
|
||||
config = build_config(options, fixtureless_options + common_options)
|
||||
fixtureless_screenshot_config = FixturelessScreenshotConfig(**config)
|
||||
generate_screenshot_from_config(options.integration[0], fixtureless_screenshot_config)
|
||||
|
||||
elif options.integration:
|
||||
for integration in options.integration:
|
||||
assert integration in INTEGRATIONS
|
||||
configs = INTEGRATIONS[integration].screenshot_configs
|
||||
if configs is None:
|
||||
parser.error(f"Could not find screenshot configuration for integration {integration}. ")
|
||||
for screenshot_config in configs:
|
||||
generate_screenshot_from_config(integration, screenshot_config)
|
||||
|
||||
else:
|
||||
parser.error(
|
||||
"Could not find configuration for integration. "
|
||||
"You can specify a fixture file to use, using the --fixture flag. "
|
||||
"Or add a configuration to the corresponding integration in zerver.lib.integrations.INTEGRATIONS.",
|
||||
)
|
||||
295
tools/screenshots/generate-user-messages-screenshot
Executable file
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or update a message thread screenshot using a thread file."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
SCREENSHOTS_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
TOOLS_DIR = os.path.abspath(os.path.dirname(SCREENSHOTS_DIR))
|
||||
ROOT_DIR = os.path.dirname(TOOLS_DIR)
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
# check for the venv
|
||||
from tools.lib import sanity_check
|
||||
|
||||
sanity_check.check_venv(__file__)
|
||||
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
import json
|
||||
|
||||
from tools.lib.test_script import prepare_puppeteer_run
|
||||
from zerver.actions.create_user import do_create_user
|
||||
from zerver.actions.message_edit import check_update_message
|
||||
from zerver.actions.message_flags import do_update_message_flags
|
||||
from zerver.actions.message_send import do_send_messages, internal_prep_stream_message
|
||||
from zerver.actions.reactions import do_add_reaction
|
||||
from zerver.actions.streams import bulk_add_subscriptions, do_change_subscription_property
|
||||
from zerver.actions.user_groups import check_add_user_group
|
||||
from zerver.actions.user_settings import do_change_avatar_fields
|
||||
from zerver.lib.emoji import get_emoji_data
|
||||
from zerver.lib.message import SendMessageRequest, access_message
|
||||
from zerver.lib.mime_types import guess_type
|
||||
from zerver.lib.stream_subscription import get_active_subscriptions_for_stream_id
|
||||
from zerver.lib.streams import access_stream_by_id, ensure_stream
|
||||
from zerver.lib.timestamp import datetime_to_timestamp
|
||||
from zerver.lib.upload import upload_avatar_image
|
||||
from zerver.lib.url_encoding import topic_narrow_url
|
||||
from zerver.models import Message, UserProfile
|
||||
from zerver.models.groups import NamedUserGroup
|
||||
from zerver.models.realms import get_realm
|
||||
from zerver.models.users import get_system_bot, get_user_by_delivery_email
|
||||
|
||||
realm = get_realm("zulip")
|
||||
realm.message_content_edit_limit_seconds = None
|
||||
realm.save()
|
||||
|
||||
DEFAULT_USER = get_user_by_delivery_email("iago@zulip.com", realm)
|
||||
NOTIFICATION_BOT = get_system_bot(settings.NOTIFICATION_BOT, realm.id)
|
||||
message_thread_ids: list[int] = []
|
||||
|
||||
USER_AVATARS_MAP = {
|
||||
"Ariella Drake": "tools/screenshots/user_avatars/AriellaDrake.png",
|
||||
"Elena García": "tools/screenshots/user_avatars/ElenaGarcia.jpg",
|
||||
"Kevin Lin": "tools/screenshots/user_avatars/KevinLin.png",
|
||||
"Zoe Davis": "tools/screenshots/user_avatars/ZoeDavis.png",
|
||||
"Bo Williams": "tools/screenshots/user_avatars/BoWilliams.png",
|
||||
"James Williams": "tools/screenshots/user_avatars/JamesWilliams.png",
|
||||
"Manvir Singh": "tools/screenshots/user_avatars/ManvirSingh.png",
|
||||
"Dal Kim": "tools/screenshots/user_avatars/DalKim.jpg",
|
||||
"John Lin": "tools/screenshots/user_avatars/JohnLin.png",
|
||||
"Maxy Stert": "tools/screenshots/user_avatars/MaxyStert.jpg",
|
||||
}
|
||||
|
||||
|
||||
class MessageThread(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
sender: str
|
||||
content: str
|
||||
starred: bool
|
||||
edited: bool
|
||||
reactions: dict[str, list[str]]
|
||||
date: dict[str, int]
|
||||
|
||||
|
||||
def create_user(full_name: str, avatar_filename: str | None) -> None:
|
||||
email = f"{full_name.replace(' ', '').replace('í', 'i')}@zulip.com"
|
||||
try:
|
||||
user = UserProfile.objects.get(realm=realm, full_name=full_name)
|
||||
except UserProfile.DoesNotExist:
|
||||
user = do_create_user(email, "password", realm, full_name, acting_user=DEFAULT_USER)
|
||||
|
||||
if avatar_filename is not None:
|
||||
set_avatar(user, avatar_filename)
|
||||
|
||||
|
||||
def set_avatar(user: UserProfile, filename: str) -> None:
|
||||
content_type = guess_type(filename)[0]
|
||||
assert content_type is not None
|
||||
with open(filename, "rb") as f:
|
||||
upload_avatar_image(f, user, content_type=content_type)
|
||||
do_change_avatar_fields(user, UserProfile.AVATAR_FROM_USER, acting_user=DEFAULT_USER)
|
||||
|
||||
|
||||
def create_and_subscribe_stream(
|
||||
stream_name: str, users: list[str], color: str | None = None, invite_only: bool = False
|
||||
) -> None:
|
||||
stream = ensure_stream(realm, stream_name, invite_only=invite_only, acting_user=DEFAULT_USER)
|
||||
bulk_add_subscriptions(
|
||||
realm,
|
||||
[stream],
|
||||
list(UserProfile.objects.filter(realm=realm, full_name__in=users)),
|
||||
acting_user=DEFAULT_USER,
|
||||
)
|
||||
|
||||
(stream, sub) = access_stream_by_id(DEFAULT_USER, stream.id)
|
||||
assert sub is not None
|
||||
if color is not None:
|
||||
do_change_subscription_property(
|
||||
DEFAULT_USER, sub, stream, "color", color, acting_user=DEFAULT_USER
|
||||
)
|
||||
|
||||
|
||||
def send_stream_messages(
|
||||
stream_name: str, topic: str, staged_messages_data: list[MessageThread]
|
||||
) -> list[int]:
|
||||
staged_messages = [dict(staged_message) for staged_message in staged_messages_data]
|
||||
stream = ensure_stream(realm, stream_name, acting_user=DEFAULT_USER)
|
||||
subscribers_query = get_active_subscriptions_for_stream_id(
|
||||
stream.id, include_deactivated_users=False
|
||||
).values_list("user_profile", flat=True)
|
||||
|
||||
subscribers: dict[str, UserProfile] = {}
|
||||
for subscriber_id in subscribers_query:
|
||||
subscriber = UserProfile.objects.get(realm=realm, id=subscriber_id)
|
||||
subscribers[subscriber.full_name] = subscriber
|
||||
|
||||
subscribers["Notification Bot"] = NOTIFICATION_BOT
|
||||
|
||||
messages: list[SendMessageRequest | None] = []
|
||||
|
||||
for message in staged_messages:
|
||||
date_sent = message["date"]
|
||||
|
||||
message_request = internal_prep_stream_message(
|
||||
subscribers[message["sender"]],
|
||||
stream,
|
||||
topic,
|
||||
message["content"],
|
||||
forged=True,
|
||||
forged_timestamp=datetime_to_timestamp(
|
||||
datetime(
|
||||
date_sent["year"],
|
||||
date_sent["month"],
|
||||
date_sent["day"],
|
||||
date_sent["hour"],
|
||||
date_sent["minute"],
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
),
|
||||
)
|
||||
messages.append(message_request)
|
||||
|
||||
message_ids = [
|
||||
sent_message_result.message_id for sent_message_result in do_send_messages(messages)
|
||||
]
|
||||
global message_thread_ids
|
||||
message_thread_ids += message_ids
|
||||
|
||||
for message, message_id in zip(staged_messages, message_ids, strict=False):
|
||||
if message.get("reactions") is not None:
|
||||
reactions = message["reactions"]
|
||||
for reaction, user_names in reactions.items():
|
||||
users = [subscribers[user_name] for user_name in user_names]
|
||||
add_message_reactions(message_id, reaction, users)
|
||||
|
||||
if message.get("starred"):
|
||||
do_update_message_flags(DEFAULT_USER, "add", "starred", [message_id])
|
||||
|
||||
if message.get("edited"):
|
||||
sender = UserProfile.objects.get(realm=realm, full_name=message["sender"])
|
||||
updated_content = message["content"] + " "
|
||||
check_update_message(sender, message_id, content=updated_content)
|
||||
|
||||
return message_ids
|
||||
|
||||
|
||||
def add_message_reactions(message_id: int, emoji: str, users: list[UserProfile]) -> None:
|
||||
preview_message = access_message(
|
||||
user_profile=DEFAULT_USER, message_id=message_id, is_modifying_message=False
|
||||
)
|
||||
emoji_data = get_emoji_data(realm.id, emoji)
|
||||
for user in users:
|
||||
do_add_reaction(
|
||||
user, preview_message, emoji, emoji_data.emoji_code, emoji_data.reaction_type
|
||||
)
|
||||
|
||||
|
||||
def create_user_group(group_name: str, members: list[str]) -> None:
|
||||
member_profiles = [
|
||||
UserProfile.objects.get(realm=realm, full_name=member_name) for member_name in members
|
||||
]
|
||||
member_profiles.append(DEFAULT_USER)
|
||||
check_add_user_group(realm, group_name, member_profiles, acting_user=DEFAULT_USER)
|
||||
|
||||
|
||||
def capture_streams_narrow_screenshot(
|
||||
image_path: str, stream_name: str, topic: str, unread_msg_id: int
|
||||
) -> None:
|
||||
stream = ensure_stream(realm, stream_name, acting_user=DEFAULT_USER)
|
||||
|
||||
narrow_uri = topic_narrow_url(realm=realm, stream=stream, topic_name=topic)
|
||||
narrow = f"{stream_name}/{topic}"
|
||||
screenshot_script = os.path.join(SCREENSHOTS_DIR, "thread-screenshot.ts")
|
||||
subprocess.check_call(
|
||||
["node", screenshot_script, narrow_uri, narrow, str(unread_msg_id), image_path, realm.url]
|
||||
)
|
||||
|
||||
|
||||
DESCRIPTION = """
|
||||
Generate screenshots of messages for corporate pages.
|
||||
|
||||
This script takes a json file with conversation details, and runs
|
||||
tools/thread-screenshot.ts to take cropped screenshots of the
|
||||
generated messages with puppeteer.
|
||||
|
||||
Make sure you have the dev environment up and running in a separate
|
||||
terminal window when you run the script.
|
||||
|
||||
Note that you will often want to update the content of existing
|
||||
json files (e.g., tools/screenshots/for-events.json) when you are
|
||||
taking updated screenshots. For example, updating any dates for
|
||||
the current year is recommended.
|
||||
|
||||
Also, note that you may need to adjust the viewport width in the
|
||||
puppeteer code and/or update the channel or topic names in the json
|
||||
content so that message header bars don't have truncated (...)
|
||||
channel or topic names due to web app UI changes.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
|
||||
group.add_argument(
|
||||
"--thread",
|
||||
nargs="+",
|
||||
help="Path of the file where the thread for screenshot is present",
|
||||
)
|
||||
fixture_group = parser.add_argument_group("thread")
|
||||
|
||||
options = parser.parse_args()
|
||||
prepare_puppeteer_run()
|
||||
|
||||
try:
|
||||
realm = get_realm("zulip")
|
||||
with open(options.thread[0]) as f:
|
||||
threads = json.load(f)
|
||||
for thread in threads:
|
||||
for user in thread["users"]:
|
||||
user_avatar = USER_AVATARS_MAP.get(user)
|
||||
create_user(user, user_avatar)
|
||||
|
||||
if thread["recipient_type"] == "channel":
|
||||
users = list(thread["users"].keys())
|
||||
users.append("Iago")
|
||||
|
||||
if thread.get("user_groups") is not None:
|
||||
for user_group in thread.get("user_groups"):
|
||||
user_grp = NamedUserGroup.objects.filter(
|
||||
name=user_group["group_name"], realm_for_sharding=realm
|
||||
).first()
|
||||
if user_grp is not None:
|
||||
user_grp.delete()
|
||||
|
||||
create_user_group(user_group["group_name"], user_group["members"])
|
||||
|
||||
invite_only = (
|
||||
False if thread.get("invite_only") is None else thread.get("invite_only")
|
||||
)
|
||||
create_and_subscribe_stream(thread["channel"], users, thread["color"], invite_only)
|
||||
message_ids = send_stream_messages(
|
||||
thread["channel"], thread["topic"], thread["messages"]
|
||||
)
|
||||
capture_streams_narrow_screenshot(
|
||||
thread["screenshot"], thread["channel"], thread["topic"], min(message_ids)
|
||||
)
|
||||
|
||||
|
||||
finally:
|
||||
Message.objects.filter(id__in=message_thread_ids).delete()
|
||||
99
tools/screenshots/message-screenshot.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/* global CSS, zulip_test */
|
||||
|
||||
import * as assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {parseArgs} from "node:util";
|
||||
|
||||
import "css.escape";
|
||||
import * as puppeteer from "puppeteer";
|
||||
import * as z from "zod/mini";
|
||||
|
||||
const usage = "Usage: message-screenshot.ts <message_id> <image_path> <realm_url>";
|
||||
const {
|
||||
values: {help},
|
||||
positionals,
|
||||
} = parseArgs({options: {help: {type: "boolean"}}, allowPositionals: true});
|
||||
|
||||
if (help) {
|
||||
console.log(usage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parsed = z
|
||||
.tuple([
|
||||
z.string(),
|
||||
z.templateLiteral([z.string(), z.enum([".png", ".jpeg", ".webp"])]),
|
||||
z.url(),
|
||||
])
|
||||
.safeParse(positionals);
|
||||
if (!parsed.success) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
const [messageId, imagePath, realmUrl] = parsed.data;
|
||||
|
||||
console.log(`Capturing screenshot for message ${messageId} to ${imagePath}`);
|
||||
|
||||
// TODO: Refactor to share code with web/e2e-tests/realm-creation.test.ts
|
||||
async function run(): Promise<void> {
|
||||
const browser = await puppeteer.launch({
|
||||
args: [
|
||||
"--window-size=1400,1024",
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
// Helps render fonts correctly on Ubuntu: https://github.com/puppeteer/puppeteer/issues/661
|
||||
"--font-render-hinting=none",
|
||||
],
|
||||
defaultViewport: null,
|
||||
headless: true,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
// deviceScaleFactor:2 gives better quality screenshots (higher pixel density)
|
||||
await page.setViewport({width: 1280, height: 1024, deviceScaleFactor: 2});
|
||||
await page.goto(`${realmUrl}/devlogin`);
|
||||
// wait for Iago devlogin button and click on it.
|
||||
await page.waitForSelector('[value="iago@zulip.com"]');
|
||||
|
||||
// By waiting till DOMContentLoaded we're confirming that Iago is logged in.
|
||||
await Promise.all([
|
||||
page.waitForNavigation({waitUntil: "domcontentloaded"}),
|
||||
page.click('[value="iago@zulip.com"]'),
|
||||
]);
|
||||
|
||||
// Navigate to message and capture screenshot
|
||||
await page.goto(`${realmUrl}/#narrow/id/${messageId}`, {
|
||||
waitUntil: "networkidle2",
|
||||
});
|
||||
const message_list_id = await page.evaluate(() => zulip_test.current_msg_list?.id);
|
||||
assert.ok(message_list_id !== undefined);
|
||||
const messageSelector = `#message-row-${message_list_id}-${CSS.escape(messageId)}`;
|
||||
await page.waitForSelector(messageSelector);
|
||||
// remove unread marker and don't select message
|
||||
const marker = `#message-row-${message_list_id}-${CSS.escape(messageId)} .unread_marker`;
|
||||
await page.$eval(marker, (element) => {
|
||||
element.remove();
|
||||
});
|
||||
const messageBox = await page.$(messageSelector);
|
||||
assert.ok(messageBox !== null);
|
||||
await page.$eval(messageSelector, (element) => {
|
||||
element.classList.remove("selected_message");
|
||||
});
|
||||
const messageGroup = await messageBox.$("xpath/..");
|
||||
assert.ok(messageGroup !== null);
|
||||
// Compute screenshot area, with some padding around the message group
|
||||
const box = await messageGroup.boundingBox();
|
||||
assert.ok(box !== null);
|
||||
const imageDir = path.dirname(imagePath);
|
||||
await fs.promises.mkdir(imageDir, {recursive: true});
|
||||
await page.screenshot({
|
||||
path: imagePath,
|
||||
clip: {x: box.x - 5, y: box.y + 5, width: box.width + 10, height: box.height},
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
126
tools/screenshots/thread-screenshot.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/* global CSS, zulip_test */
|
||||
|
||||
import * as assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {parseArgs} from "node:util";
|
||||
|
||||
import "css.escape";
|
||||
import * as puppeteer from "puppeteer";
|
||||
import * as z from "zod/mini";
|
||||
|
||||
const usage =
|
||||
"Usage: thread-screenshot.ts <narrow_uri> <narrow> <message_id> <image_path> <realm_url>";
|
||||
const {
|
||||
values: {help},
|
||||
positionals,
|
||||
} = parseArgs({options: {help: {type: "boolean"}}, allowPositionals: true});
|
||||
|
||||
if (help) {
|
||||
console.log(usage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parsed = z
|
||||
.tuple([
|
||||
z.url(),
|
||||
z.string(),
|
||||
z.string(),
|
||||
z.templateLiteral([z.string(), z.enum([".png", ".jpeg", ".webp"])]),
|
||||
z.url(),
|
||||
])
|
||||
.safeParse(positionals);
|
||||
if (!parsed.success) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
const [narrowUri, narrow, messageId, imagePath, realmUrl] = parsed.data;
|
||||
|
||||
console.log(`Capturing screenshot for ${narrow} to ${imagePath}`);
|
||||
|
||||
// TODO: Refactor to share code with web/e2e-tests/realm-creation.test.ts
|
||||
async function run(): Promise<void> {
|
||||
const browser = await puppeteer.launch({
|
||||
args: [
|
||||
"--window-size=500,1024",
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
// Helps render fonts correctly on Ubuntu: https://github.com/puppeteer/puppeteer/issues/661
|
||||
"--font-render-hinting=none",
|
||||
],
|
||||
defaultViewport: null,
|
||||
headless: true,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
// deviceScaleFactor:2 gives better quality screenshots (higher pixel density)
|
||||
await page.setViewport({width: 580, height: 1024, deviceScaleFactor: 2});
|
||||
await page.goto(`${realmUrl}/devlogin`);
|
||||
// wait for Iago devlogin button and click on it.
|
||||
await page.waitForSelector('[value="iago@zulip.com"]');
|
||||
|
||||
// By waiting till DOMContentLoaded we're confirming that Iago is logged in.
|
||||
await Promise.all([
|
||||
page.waitForNavigation({waitUntil: "domcontentloaded"}),
|
||||
page.click('[value="iago@zulip.com"]'),
|
||||
]);
|
||||
|
||||
// Close any banner at the top of the app before taking any screenshots.
|
||||
const top_banner_close_button_selector = ".banner-close-action";
|
||||
await page.waitForSelector(top_banner_close_button_selector);
|
||||
await page.click(top_banner_close_button_selector);
|
||||
|
||||
// Navigate to message and capture screenshot
|
||||
await page.goto(narrowUri, {
|
||||
waitUntil: "networkidle2",
|
||||
});
|
||||
const message_list_id = await page.evaluate(() => zulip_test.current_msg_list?.id);
|
||||
assert.ok(message_list_id !== undefined);
|
||||
const messageListSelector = "#message-lists-container";
|
||||
await page.waitForSelector(messageListSelector);
|
||||
|
||||
// remove unread marker and don't select message
|
||||
const marker = `.message-list[data-message-list-id="${CSS.escape(
|
||||
`${message_list_id}`,
|
||||
)}"] .unread_marker`;
|
||||
await page.$eval(marker, (element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
const messageSelector = `#message-row-${message_list_id}-${CSS.escape(messageId)}`;
|
||||
await page.waitForSelector(messageSelector);
|
||||
|
||||
const messageListBox = await page.$(messageListSelector);
|
||||
assert.ok(messageListBox !== null);
|
||||
await page.$eval(messageSelector, (element) => {
|
||||
element.classList.remove("selected_message");
|
||||
});
|
||||
|
||||
// This is done so as to get white background while capturing screenshots.
|
||||
const background_selectors = [".app-main", ".message-feed", ".message_header"];
|
||||
await page.$$eval(background_selectors.join(","), (elements) => {
|
||||
for (const element of elements) {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.backgroundColor = "white";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// This is done so that the message control buttons are not visible.
|
||||
await page.hover(".compose_new_conversation_button");
|
||||
|
||||
// Compute screenshot area, with some padding around the message group
|
||||
const box = await messageListBox.boundingBox();
|
||||
assert.ok(box !== null);
|
||||
const imageDir = path.dirname(imagePath);
|
||||
await fs.promises.mkdir(imageDir, {recursive: true});
|
||||
await page.screenshot({
|
||||
path: imagePath,
|
||||
clip: {x: box.x, y: box.y + 10, width: box.width - 70, height: box.height - 8},
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
BIN
tools/screenshots/user_avatars/AriellaDrake.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
tools/screenshots/user_avatars/BoWilliams.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
tools/screenshots/user_avatars/DalKim.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
tools/screenshots/user_avatars/ElenaGarcia.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
tools/screenshots/user_avatars/JamesWilliams.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
tools/screenshots/user_avatars/JohnLin.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
tools/screenshots/user_avatars/KevinLin.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
tools/screenshots/user_avatars/ManvirSingh.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
tools/screenshots/user_avatars/MaxyStert.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
tools/screenshots/user_avatars/ZoeDavis.png
Normal file
|
After Width: | Height: | Size: 23 KiB |