markdown: Remove most of Twitter integration.

Twitter removed their v1 API. We take care to keep the existing cached
results around for now, and to not poison that cache, since we might
be able replace this with something that can still use the existing
cache.
This commit is contained in:
Tim Abbott 2023-05-29 09:19:45 -07:00
parent ecc1433d02
commit dce4a3c98e
3 changed files with 12 additions and 546 deletions

View File

@ -65,7 +65,7 @@ from zerver.lib.outgoing_http import OutgoingSession
from zerver.lib.subdomains import is_static_or_current_realm_url from zerver.lib.subdomains import is_static_or_current_realm_url
from zerver.lib.tex import render_tex from zerver.lib.tex import render_tex
from zerver.lib.thumbnail import user_uploads_or_external from zerver.lib.thumbnail import user_uploads_or_external
from zerver.lib.timeout import TimeoutExpiredError, timeout from zerver.lib.timeout import timeout
from zerver.lib.timezone import common_timezones from zerver.lib.timezone import common_timezones
from zerver.lib.types import LinkifierDict from zerver.lib.types import LinkifierDict
from zerver.lib.url_encoding import encode_stream, hash_util_encode from zerver.lib.url_encoding import encode_stream, hash_util_encode
@ -420,68 +420,13 @@ def has_blockquote_ancestor(element_pair: Optional[ElementPair]) -> bool:
@cache_with_key(lambda tweet_id: tweet_id, cache_name="database") @cache_with_key(lambda tweet_id: tweet_id, cache_name="database")
def fetch_tweet_data(tweet_id: str) -> Optional[Dict[str, Any]]: def fetch_tweet_data(tweet_id: str) -> Optional[Dict[str, Any]]:
if settings.TEST_SUITE: # Twitter removed support for the v1 API that this integration
from . import testing_mocks # used. Given that, there's no point wasting time trying to make
# network requests to Twitter. But we leave this function, because
res = testing_mocks.twitter(tweet_id) # existing cached renderings for Tweets is useful. We throw an
else: # exception rather than returning `None` to avoid caching that the
creds = { # link doesn't exist.
"consumer_key": settings.TWITTER_CONSUMER_KEY, raise NotImplementedError("Twitter desupported their v1 API")
"consumer_secret": settings.TWITTER_CONSUMER_SECRET,
"access_token_key": settings.TWITTER_ACCESS_TOKEN_KEY,
"access_token_secret": settings.TWITTER_ACCESS_TOKEN_SECRET,
}
if not all(creds.values()):
return None
# We lazily import twitter here because its import process is
# surprisingly slow, and doing so has a significant impact on
# the startup performance of `manage.py` commands.
import twitter
api = twitter.Api(tweet_mode="extended", **creds)
try:
# Sometimes Twitter hangs on responses. Timing out here
# will cause the Tweet to go through as-is with no inline
# preview, rather than having the message be rejected
# entirely. This timeout needs to be less than our overall
# formatting timeout.
tweet = timeout(3, lambda: api.GetStatus(tweet_id))
res = tweet.AsDict()
except TimeoutExpiredError:
# We'd like to try again later and not cache the bad result,
# so we need to re-raise the exception (just as though
# we were being rate-limited)
raise
except twitter.TwitterError as e:
t = e.args[0]
if len(t) == 1 and ("code" in t[0]):
# https://developer.twitter.com/en/docs/basics/response-codes
code = t[0]["code"]
if code in [34, 144, 421, 422]:
# All these "correspond with HTTP 404," and mean
# that the message doesn't exist; return None so
# that we will cache the error.
return None
elif code in [63, 179]:
# 63 is that the account is suspended, 179 is that
# it is now locked; cache the None.
return None
elif code in [88, 130, 131]:
# Code 88 means that we were rate-limited, 130
# means Twitter is having capacity issues, and 131
# is other 400-equivalent; in these cases, raise
# the error so we don't cache None and will try
# again later.
raise
# It's not clear what to do in cases of other errors,
# but for now it seems reasonable to log at error
# level (so that we get notified), but then cache the
# failure to proceed with our usual work
markdown_logger.exception("Unknown error fetching tweet data", stack_info=True)
return None
return res
class OpenGraphSession(OutgoingSession): class OpenGraphSession(OutgoingSession):
@ -1049,6 +994,8 @@ class InlineInterestingLinkProcessor(markdown.treeprocessors.Treeprocessor):
img.set("src", media_url) img.set("src", media_url)
return tweet return tweet
except NotImplementedError:
return None
except Exception: except Exception:
# We put this in its own try-except because it requires external # We put this in its own try-except because it requires external
# connectivity. If Twitter flakes out, we don't want to not-render # connectivity. If Twitter flakes out, we don't want to not-render

View File

@ -1,233 +0,0 @@
from typing import Any, Dict, Optional
import orjson
NORMAL_TWEET = """{
"created_at": "Sat Sep 10 22:23:38 +0000 2011",
"favorite_count": 1,
"full_text": "@twitter meets @seepicturely at #tcdisrupt cc.@boscomonkey @episod http://t.co/6J2EgYM",
"hashtags": [
{
"text": "tcdisrupt"
}
],
"id": 112652479837110270,
"id_str": "112652479837110273",
"in_reply_to_screen_name": "Twitter",
"in_reply_to_user_id": 783214,
"lang": "en",
"retweet_count": 4,
"source": "<a href=\\"http://instagram.com\\" rel=\\"nofollow\\">Instagram</a>",
"urls": [
{
"expanded_url": "http://instagr.am/p/MuW67/",
"url": "http://t.co/6J2EgYM"
}
],
"user": {
"created_at": "Mon May 16 20:07:59 +0000 2011",
"description": "Eoin's photography account. See @mceoin for tweets.",
"followers_count": 3,
"id": 299862462,
"lang": "en",
"location": "Twitter",
"name": "Eoin McMillan",
"profile_background_color": "131516",
"profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif",
"profile_background_tile": true,
"profile_image_url": "http://pbs.twimg.com/profile_images/1380912173/Screen_shot_2011-06-03_at_7.35.36_PM_normal.png",
"profile_link_color": "009999",
"profile_sidebar_fill_color": "EFEFEF",
"profile_text_color": "333333",
"screen_name": "imeoin",
"statuses_count": 278,
"url": "http://t.co/p9hKpiGMyN"
},
"user_mentions": [
{
"id": 783214,
"name": "Twitter",
"screen_name": "Twitter"
},
{
"id": 14792670,
"name": "Bosco So",
"screen_name": "boscomonkey"
},
{
"id": 819797,
"name": "Taylor Singletary",
"screen_name": "episod"
}
]
}"""
MENTION_IN_LINK_TWEET = """{
"created_at": "Sat Sep 10 22:23:38 +0000 2011",
"favorite_count": 1,
"full_text": "http://t.co/@foo",
"hashtags": [
{
"text": "tcdisrupt"
}
],
"id": 112652479837110270,
"id_str": "112652479837110273",
"in_reply_to_screen_name": "Twitter",
"in_reply_to_user_id": 783214,
"lang": "en",
"retweet_count": 4,
"source": "<a href=\\"http://instagram.com\\" rel=\\"nofollow\\">Instagram</a>",
"urls": [
{
"expanded_url": "http://foo.com",
"url": "http://t.co/@foo"
}
],
"user": {
"created_at": "Mon May 16 20:07:59 +0000 2011",
"description": "Eoin's photography account. See @mceoin for tweets.",
"followers_count": 3,
"id": 299862462,
"lang": "en",
"location": "Twitter",
"name": "Eoin McMillan",
"profile_background_color": "131516",
"profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif",
"profile_background_tile": true,
"profile_image_url": "http://pbs.twimg.com/profile_images/1380912173/Screen_shot_2011-06-03_at_7.35.36_PM_normal.png",
"profile_link_color": "009999",
"profile_sidebar_fill_color": "EFEFEF",
"profile_text_color": "333333",
"screen_name": "imeoin",
"statuses_count": 278,
"url": "http://t.co/p9hKpiGMyN"
},
"user_mentions": [
{
"id": 783214,
"name": "Foo",
"screen_name": "foo"
}
]
}"""
MEDIA_TWEET = """{
"created_at": "Sat Sep 10 22:23:38 +0000 2011",
"favorite_count": 1,
"full_text": "http://t.co/xo7pAhK6n3",
"id": 112652479837110270,
"id_str": "112652479837110273",
"in_reply_to_screen_name": "Twitter",
"in_reply_to_user_id": 783214,
"lang": "en",
"media": [
{
"display_url": "pic.twitter.com/xo7pAhK6n3",
"expanded_url": "http://twitter.com/NEVNBoston/status/421654515616849920/photo/1",
"id": 421654515495211010,
"media_url": "http://pbs.twimg.com/media/BdoEjD4IEAIq86Z.jpg",
"media_url_https": "https://pbs.twimg.com/media/BdoEjD4IEAIq86Z.jpg",
"sizes": {"large": {"h": 700, "resize": "fit", "w": 1024},
"medium": {"h": 410, "resize": "fit", "w": 599},
"small": {"h": 232, "resize": "fit", "w": 340},
"thumb": {"h": 150, "resize": "crop", "w": 150}},
"type": "photo",
"url": "http://t.co/xo7pAhK6n3"}
],
"retweet_count": 4,
"source": "<a href=\\"http://instagram.com\\" rel=\\"nofollow\\">Instagram</a>",
"user": {
"created_at": "Mon May 16 20:07:59 +0000 2011",
"description": "Eoin's photography account. See @mceoin for tweets.",
"followers_count": 3,
"id": 299862462,
"lang": "en",
"location": "Twitter",
"name": "Eoin McMillan",
"profile_background_color": "131516",
"profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif",
"profile_background_tile": true,
"profile_image_url": "http://pbs.twimg.com/profile_images/1380912173/Screen_shot_2011-06-03_at_7.35.36_PM_normal.png",
"profile_link_color": "009999",
"profile_sidebar_fill_color": "EFEFEF",
"profile_text_color": "333333",
"screen_name": "imeoin",
"statuses_count": 278,
"url": "http://t.co/p9hKpiGMyN"
},
"user_mentions": [
{
"id": 783214,
"name": "Foo",
"screen_name": "foo"
}
]
}"""
EMOJI_TWEET = """{
"created_at": "Sat Sep 10 22:23:38 +0000 2011",
"favorite_count": 1,
"full_text": "Zulip is 💯% open-source!",
"hashtags": [
{
"text": "tcdisrupt"
}
],
"id": 112652479837110270,
"id_str": "112652479837110273",
"in_reply_to_screen_name": "Twitter",
"in_reply_to_user_id": 783214,
"lang": "en",
"retweet_count": 4,
"source": "<a href=\\"http://instagram.com\\" rel=\\"nofollow\\">Instagram</a>",
"user": {
"created_at": "Mon May 16 20:07:59 +0000 2011",
"description": "Eoin's photography account. See @mceoin for tweets.",
"followers_count": 3,
"id": 299862462,
"lang": "en",
"location": "Twitter",
"name": "Eoin McMillan",
"profile_background_color": "131516",
"profile_background_image_url": "http://abs.twimg.com/images/themes/theme14/bg.gif",
"profile_background_tile": true,
"profile_image_url": "http://pbs.twimg.com/profile_images/1380912173/Screen_shot_2011-06-03_at_7.35.36_PM_normal.png",
"profile_link_color": "009999",
"profile_sidebar_fill_color": "EFEFEF",
"profile_text_color": "333333",
"screen_name": "imeoin",
"statuses_count": 278,
"url": "http://t.co/p9hKpiGMyN"
},
"user_mentions": [
{
"id": 783214,
"name": "Twitter",
"screen_name": "Twitter"
},
{
"id": 14792670,
"name": "Bosco So",
"screen_name": "boscomonkey"
},
{
"id": 819797,
"name": "Taylor Singletary",
"screen_name": "episod"
}
]
}"""
def twitter(tweet_id: str) -> Optional[Dict[str, Any]]:
if tweet_id in ["112652479837110273", "287977969287315456", "287977969287315457"]:
return orjson.loads(NORMAL_TWEET)
elif tweet_id == "287977969287315458":
return orjson.loads(MENTION_IN_LINK_TWEET)
elif tweet_id == "287977969287315459":
return orjson.loads(MEDIA_TWEET)
elif tweet_id == "287977969287315460":
return orjson.loads(EMOJI_TWEET)
else:
return None

View File

@ -950,258 +950,10 @@ class MarkdownTest(ZulipTestCase):
"410766290349879296", "410766290349879296",
) )
def test_inline_interesting_links(self) -> None:
def make_link(url: str) -> str:
return f'<a href="{url}">{url}</a>'
normal_tweet_html = (
'<a href="https://twitter.com/Twitter"'
">@Twitter</a> "
"meets @seepicturely at #tcdisrupt cc."
'<a href="https://twitter.com/boscomonkey"'
">@boscomonkey</a> "
'<a href="https://twitter.com/episod"'
">@episod</a> "
'<a href="http://t.co/6J2EgYM"'
">http://instagr.am/p/MuW67/</a>"
)
mention_in_link_tweet_html = """<a href="http://t.co/@foo">http://foo.com</a>"""
media_tweet_html = (
'<a href="http://t.co/xo7pAhK6n3">'
"http://twitter.com/NEVNBoston/status/421654515616849920/photo/1</a>"
)
emoji_in_tweet_html = """Zulip is <span aria-label=\"100\" class="emoji emoji-1f4af" role=\"img\" title="100">:100:</span>% open-source!"""
def make_inline_twitter_preview(url: str, tweet_html: str, image_html: str = "") -> str:
## As of right now, all previews are mocked to be the exact same tweet
return (
'<div class="inline-preview-twitter">'
'<div class="twitter-tweet">'
f'<a href="{url}">'
'<img class="twitter-avatar"'
' src="https://external-content.zulipcdn.net/external_content/1f7cd2436976d410eab8189ebceda87ae0b34ead/687474703a2f2f7062732e7477696d672e63'
"6f6d2f70726f66696c655f696d616765732f313338303931323137332f53637265656e5f73686f745f323031312d30362d30335f61745f372e33352e33"
'365f504d5f6e6f726d616c2e706e67">'
"</a>"
f"<p>{tweet_html}</p>"
"<span>- Eoin McMillan (@imeoin)</span>"
f"{image_html}"
"</div>"
"</div>"
)
msg = "http://www.twitter.com"
converted = markdown_convert_wrapper(msg)
self.assertEqual(converted, "<p>{}</p>".format(make_link("http://www.twitter.com")))
msg = "http://www.twitter.com/wdaher/"
converted = markdown_convert_wrapper(msg)
self.assertEqual(converted, "<p>{}</p>".format(make_link("http://www.twitter.com/wdaher/")))
msg = "http://www.twitter.com/wdaher/status/3"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted, "<p>{}</p>".format(make_link("http://www.twitter.com/wdaher/status/3"))
)
# id too long
msg = "http://www.twitter.com/wdaher/status/2879779692873154569"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>".format(
make_link("http://www.twitter.com/wdaher/status/2879779692873154569")
),
)
# id too large (i.e. tweet doesn't exist)
msg = "http://www.twitter.com/wdaher/status/999999999999999999"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>".format(
make_link("http://www.twitter.com/wdaher/status/999999999999999999")
),
)
msg = "http://www.twitter.com/wdaher/status/287977969287315456"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>\n{}".format(
make_link("http://www.twitter.com/wdaher/status/287977969287315456"),
make_inline_twitter_preview(
"http://www.twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
),
)
msg = "https://www.twitter.com/wdaher/status/287977969287315456"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>\n{}".format(
make_link("https://www.twitter.com/wdaher/status/287977969287315456"),
make_inline_twitter_preview(
"https://www.twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
),
)
msg = "http://twitter.com/wdaher/status/287977969287315456"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>\n{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315456"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
),
)
# Repeated links will only be converted once
msg = (
"http://twitter.com/wdaher/status/287977969287315456 "
"http://twitter.com/wdaher/status/287977969287315457 "
"http://twitter.com/wdaher/status/287977969287315457 "
"http://twitter.com/wdaher/status/287977969287315457"
)
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{} {} {} {}</p>\n{}{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315456"),
make_link("http://twitter.com/wdaher/status/287977969287315457"),
make_link("http://twitter.com/wdaher/status/287977969287315457"),
make_link("http://twitter.com/wdaher/status/287977969287315457"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315457", normal_tweet_html
),
),
)
# A max of 3 will be converted
msg = (
"http://twitter.com/wdaher/status/287977969287315456 "
"http://twitter.com/wdaher/status/287977969287315457 "
"https://twitter.com/wdaher/status/287977969287315456 "
"http://twitter.com/wdaher/status/287977969287315460"
)
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{} {} {} {}</p>\n{}{}{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315456"),
make_link("http://twitter.com/wdaher/status/287977969287315457"),
make_link("https://twitter.com/wdaher/status/287977969287315456"),
make_link("http://twitter.com/wdaher/status/287977969287315460"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315457", normal_tweet_html
),
make_inline_twitter_preview(
"https://twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
),
)
# Test smart in-place inlining behavior:
msg = (
"Paragraph 1: http://twitter.com/wdaher/status/287977969287315456\n\n"
"Paragraph 2\n\n"
"Paragraph 3: http://twitter.com/wdaher/status/287977969287315457"
)
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>Paragraph 1: {}</p>\n{}<p>Paragraph 2</p>\n<p>Paragraph 3: {}</p>\n{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315456"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
make_link("http://twitter.com/wdaher/status/287977969287315457"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315457", normal_tweet_html
),
),
)
# Tweet has a mention in a URL, only the URL is linked
msg = "http://twitter.com/wdaher/status/287977969287315458"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>\n{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315458"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315458",
mention_in_link_tweet_html,
),
),
)
# Tweet with an image
msg = "http://twitter.com/wdaher/status/287977969287315459"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>\n{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315459"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315459",
media_tweet_html,
(
'<div class="twitter-image">'
'<a href="http://t.co/xo7pAhK6n3">'
f"""<img src="{get_camo_url("https://pbs.twimg.com/media/BdoEjD4IEAIq86Z.jpg:small")}">"""
"</a>"
"</div>"
),
),
),
)
msg = "http://twitter.com/wdaher/status/287977969287315460"
converted = markdown_convert_wrapper(msg)
self.assertEqual(
converted,
"<p>{}</p>\n{}".format(
make_link("http://twitter.com/wdaher/status/287977969287315460"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315460", emoji_in_tweet_html
),
),
)
# Test Twitter previews in spoiler tags.
msg = "```spoiler secret tweet\nTweet: http://twitter.com/wdaher/status/287977969287315456\n```"
converted = markdown_convert_wrapper(msg)
rendered_spoiler = '<div class="spoiler-block"><div class="spoiler-header">\n<p>secret tweet</p>\n</div><div class="spoiler-content" aria-hidden="true">\n<p>Tweet: {}</p>\n{}</div></div>'
self.assertEqual(
converted,
rendered_spoiler.format(
make_link("http://twitter.com/wdaher/status/287977969287315456"),
make_inline_twitter_preview(
"http://twitter.com/wdaher/status/287977969287315456", normal_tweet_html
),
),
)
def test_fetch_tweet_data_settings_validation(self) -> None: def test_fetch_tweet_data_settings_validation(self) -> None:
with self.settings(TEST_SUITE=False, TWITTER_CONSUMER_KEY=None): with self.settings(TEST_SUITE=False, TWITTER_CONSUMER_KEY=None):
self.assertIs(None, fetch_tweet_data("287977969287315459")) with self.assertRaises(NotImplementedError):
fetch_tweet_data("287977969287315459")
def test_content_has_emoji(self) -> None: def test_content_has_emoji(self) -> None:
self.assertFalse(content_has_emoji_syntax("boring")) self.assertFalse(content_has_emoji_syntax("boring"))