Page Menu
Home
Phabricator (Chris)
Search
Configure Global Search
Log In
Files
F618626
rss.lua
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None
rss.lua
View Options
--[[
mattata v2.0 - RSS Plugin
Subscribe groups to RSS/Atom feeds with automatic polling.
Feeds are checked every 5 minutes via cron (staggered).
All state stored in Redis — no database procedures needed.
]]
local
plugin
=
{}
plugin
.
name
=
'rss'
plugin
.
category
=
'utility'
plugin
.
description
=
'Subscribe to RSS/Atom feeds in group chats'
plugin
.
commands
=
{
'rss'
}
plugin
.
help
=
'/rss add <url> - Subscribe to a feed
\n
/rss remove <url> - Unsubscribe
\n
/rss list - List active subscriptions'
plugin
.
group_only
=
true
plugin
.
admin_only
=
true
local
http
=
require
(
'src.core.http'
)
local
tools
=
require
(
'telegram-bot-lua.tools'
)
local
logger
=
require
(
'src.core.logger'
)
local
MAX_FEEDS_PER_CHAT
=
5
local
POLL_INTERVAL
=
300
-- 5 minutes between checks per feed
local
MAX_FEEDS_PER_TICK
=
3
-- max feeds to check per cron tick
local
MAX_ITEMS
=
5
-- only process latest 5 items per fetch
local
MAX_SEEN
=
200
-- keep last N entry IDs in seen set
local
function
url_hash
(
url
)
local
h
=
0
for
i
=
1
,
#
url
do
h
=
(
h
*
31
+
url
:
byte
(
i
))
%
2147483647
end
return
tostring
(
h
)
end
-- Parse RSS 2.0 items
local
function
parse_rss
(
body
)
local
items
=
{}
for
item
in
body
:
gmatch
(
'<item>(.-)</item>'
)
do
local
title
=
item
:
match
(
'<title><!%[CDATA%[(.-)%]%]>'
)
or
item
:
match
(
'<title>(.-)</title>'
)
or
'Untitled'
local
link
=
item
:
match
(
'<link>(.-)</link>'
)
or
''
local
guid
=
item
:
match
(
'<guid>(.-)</guid>'
)
or
link
title
=
title
:
gsub
(
'<[^>]+>'
,
''
)
table.insert
(
items
,
{
title
=
title
,
link
=
link
,
guid
=
guid
})
if
#
items
>=
MAX_ITEMS
then
break
end
end
return
items
end
-- Parse Atom entries
local
function
parse_atom
(
body
)
local
items
=
{}
for
entry
in
body
:
gmatch
(
'<entry>(.-)</entry>'
)
do
local
title
=
entry
:
match
(
'<title>(.-)</title>'
)
or
'Untitled'
local
link
=
entry
:
match
(
'<link[^>]*href="([^"]*)"'
)
or
entry
:
match
(
'<link>(.-)</link>'
)
or
''
local
id
=
entry
:
match
(
'<id>(.-)</id>'
)
or
link
title
=
title
:
gsub
(
'<[^>]+>'
,
''
)
table.insert
(
items
,
{
title
=
title
,
link
=
link
,
guid
=
id
})
if
#
items
>=
MAX_ITEMS
then
break
end
end
return
items
end
local
function
parse_feed
(
body
)
if
body
:
match
(
'<feed'
)
then
return
parse_atom
(
body
)
else
return
parse_rss
(
body
)
end
end
-- Extract feed title from XML
local
function
extract_feed_title
(
body
)
-- Try channel/title for RSS
local
channel
=
body
:
match
(
'<channel>(.-)<item'
)
if
channel
then
local
title
=
channel
:
match
(
'<title><!%[CDATA%[(.-)%]%]>'
)
or
channel
:
match
(
'<title>(.-)</title>'
)
if
title
then
return
title
:
gsub
(
'<[^>]+>'
,
''
)
end
end
-- Try feed/title for Atom
local
title
=
body
:
match
(
'<feed[^>]*>.-<title>(.-)</title>'
)
if
title
then
return
title
:
gsub
(
'<[^>]+>'
,
''
)
end
return
'Untitled Feed'
end
local
function
handle_add
(
api
,
message
,
ctx
)
local
url
=
message
.
args
and
message
.
args
:
match
(
'^add%s+(.+)$'
)
if
not
url
then
return
api
.
send_message
(
message
.
chat
.
id
,
'Usage: <code>/rss add https://example.com/feed.xml</code>'
,
{
parse_mode
=
'html'
})
end
url
=
url
:
match
(
'^%s*(.-)%s*$'
)
-- trim whitespace
if
not
url
:
match
(
'^https?://'
)
then
return
api
.
send_message
(
message
.
chat
.
id
,
'Invalid URL. Must start with http:// or https://'
)
end
-- Block internal/private IP ranges to prevent SSRF
local
host
=
url
:
match
(
'^https?://([^/:]+)'
)
if
host
then
local
h
=
host
:
lower
()
if
h
==
'localhost'
or
h
:
match
(
'^127%.'
)
or
h
:
match
(
'^10%.'
)
or
h
:
match
(
'^192%.168%.'
)
or
h
:
match
(
'^172%.1[6-9]%.'
)
or
h
:
match
(
'^172%.2%d%.'
)
or
h
:
match
(
'^172%.3[01]%.'
)
or
h
:
match
(
'^0%.'
)
or
h
:
match
(
'^%['
)
or
h
:
match
(
'^169%.254%.'
)
then
return
api
.
send_message
(
message
.
chat
.
id
,
'Invalid URL. Internal addresses are not allowed.'
)
end
end
local
feed_key
=
'rss:feeds:'
..
message
.
chat
.
id
local
count
=
ctx
.
redis
.
scard
(
feed_key
)
if
tonumber
(
count
)
>=
MAX_FEEDS_PER_CHAT
then
return
api
.
send_message
(
message
.
chat
.
id
,
string.format
(
'This chat already has %d subscriptions (max %d). Remove one first.'
,
count
,
MAX_FEEDS_PER_CHAT
)
)
end
-- Check if already subscribed
local
is_member
=
ctx
.
redis
.
sismember
(
feed_key
,
url
)
if
tonumber
(
is_member
)
==
1
then
return
api
.
send_message
(
message
.
chat
.
id
,
'This chat is already subscribed to that feed.'
)
end
-- Fetch and validate the feed
local
body
,
code
=
http
.
get
(
url
)
if
not
body
or
code
~=
200
then
return
api
.
send_message
(
message
.
chat
.
id
,
string.format
(
'Failed to fetch feed (HTTP %s). Check the URL and try again.'
,
tostring
(
code
))
)
end
local
items
=
parse_feed
(
body
)
if
#
items
==
0
then
return
api
.
send_message
(
message
.
chat
.
id
,
'No feed items found at that URL. Make sure it is a valid RSS or Atom feed.'
)
end
local
feed_title
=
extract_feed_title
(
body
)
local
hash
=
url_hash
(
url
)
-- Store subscription
ctx
.
redis
.
sadd
(
feed_key
,
url
)
ctx
.
redis
.
sadd
(
'rss:subs:'
..
hash
,
tostring
(
message
.
chat
.
id
))
ctx
.
redis
.
sadd
(
'rss:active_feeds'
,
hash
)
-- Store metadata
local
meta_key
=
'rss:meta:'
..
hash
ctx
.
redis
.
hset
(
meta_key
,
'title'
,
feed_title
)
ctx
.
redis
.
hset
(
meta_key
,
'url'
,
url
)
ctx
.
redis
.
hset
(
meta_key
,
'last_checked'
,
tostring
(
os.time
()))
-- Mark all current entries as seen so we don't flood on first add
local
seen_key
=
'rss:seen:'
..
hash
for
_
,
item
in
ipairs
(
items
)
do
if
item
.
guid
and
item
.
guid
~=
''
then
ctx
.
redis
.
sadd
(
seen_key
,
item
.
guid
)
end
end
return
api
.
send_message
(
message
.
chat
.
id
,
string.format
(
'Subscribed to <b>%s</b>.'
,
tools
.
escape_html
(
feed_title
)),
{
parse_mode
=
'html'
}
)
end
local
function
handle_remove
(
api
,
message
,
ctx
)
local
url
=
message
.
args
and
message
.
args
:
match
(
'^remove%s+(.+)$'
)
if
not
url
then
return
api
.
send_message
(
message
.
chat
.
id
,
'Usage: <code>/rss remove https://example.com/feed.xml</code>'
,
{
parse_mode
=
'html'
})
end
url
=
url
:
match
(
'^%s*(.-)%s*$'
)
-- trim whitespace
local
feed_key
=
'rss:feeds:'
..
message
.
chat
.
id
local
removed
=
ctx
.
redis
.
srem
(
feed_key
,
url
)
if
tonumber
(
removed
)
==
0
then
return
api
.
send_message
(
message
.
chat
.
id
,
'This chat is not subscribed to that feed.'
)
end
local
hash
=
url_hash
(
url
)
ctx
.
redis
.
srem
(
'rss:subs:'
..
hash
,
tostring
(
message
.
chat
.
id
))
-- If no more subscribers, clean up
local
remaining
=
ctx
.
redis
.
scard
(
'rss:subs:'
..
hash
)
if
tonumber
(
remaining
)
==
0
then
ctx
.
redis
.
del
(
'rss:meta:'
..
hash
)
ctx
.
redis
.
del
(
'rss:seen:'
..
hash
)
ctx
.
redis
.
srem
(
'rss:active_feeds'
,
hash
)
end
return
api
.
send_message
(
message
.
chat
.
id
,
'Unsubscribed from feed.'
)
end
local
function
handle_list
(
api
,
message
,
ctx
)
local
feed_key
=
'rss:feeds:'
..
message
.
chat
.
id
local
urls
=
ctx
.
redis
.
smembers
(
feed_key
)
if
not
urls
or
#
urls
==
0
then
return
api
.
send_message
(
message
.
chat
.
id
,
'No active feed subscriptions for this chat.'
)
end
local
lines
=
{
'<b>RSS Subscriptions</b>'
}
for
i
,
feed_url
in
ipairs
(
urls
)
do
local
hash
=
url_hash
(
feed_url
)
local
title
=
ctx
.
redis
.
hget
(
'rss:meta:'
..
hash
,
'title'
)
or
'Unknown'
table.insert
(
lines
,
string.format
(
'%d. <b>%s</b>
\n
<code>%s</code>'
,
i
,
tools
.
escape_html
(
title
),
tools
.
escape_html
(
feed_url
)))
end
return
api
.
send_message
(
message
.
chat
.
id
,
table.concat
(
lines
,
'
\n
'
),
{
parse_mode
=
'html'
,
link_preview_options
=
{
is_disabled
=
true
}
})
end
function
plugin
.
on_message
(
api
,
message
,
ctx
)
if
not
message
.
args
or
message
.
args
==
''
then
return
api
.
send_message
(
message
.
chat
.
id
,
'<b>RSS Feed Subscriptions</b>
\n\n
'
..
'<code>/rss add <url></code> - Subscribe to a feed
\n
'
..
'<code>/rss remove <url></code> - Unsubscribe
\n
'
..
'<code>/rss list</code> - List active subscriptions
\n\n
'
..
string.format
(
'Max %d feeds per chat. Feeds are checked every %d minutes.'
,
MAX_FEEDS_PER_CHAT
,
POLL_INTERVAL
/
60
),
{
parse_mode
=
'html'
}
)
end
local
subcommand
=
message
.
args
:
match
(
'^(%S+)'
)
if
not
subcommand
then
return
api
.
send_message
(
message
.
chat
.
id
,
'Unknown subcommand. Use /rss for help.'
)
end
subcommand
=
subcommand
:
lower
()
if
subcommand
==
'add'
then
return
handle_add
(
api
,
message
,
ctx
)
elseif
subcommand
==
'remove'
or
subcommand
==
'del'
or
subcommand
==
'delete'
then
return
handle_remove
(
api
,
message
,
ctx
)
elseif
subcommand
==
'list'
then
return
handle_list
(
api
,
message
,
ctx
)
else
return
api
.
send_message
(
message
.
chat
.
id
,
'Unknown subcommand. Use /rss for help.'
)
end
end
function
plugin
.
cron
(
api
,
ctx
)
-- Get all active feed hashes
local
active_feeds
=
ctx
.
redis
.
smembers
(
'rss:active_feeds'
)
if
not
active_feeds
or
#
active_feeds
==
0
then
return
end
-- Find feeds due for checking (not checked in last POLL_INTERVAL seconds)
local
now
=
os.time
()
local
due
=
{}
for
_
,
hash
in
ipairs
(
active_feeds
)
do
local
last_checked
=
ctx
.
redis
.
hget
(
'rss:meta:'
..
hash
,
'last_checked'
)
last_checked
=
tonumber
(
last_checked
)
or
0
if
now
-
last_checked
>=
POLL_INTERVAL
then
table.insert
(
due
,
hash
)
end
if
#
due
>=
MAX_FEEDS_PER_TICK
then
break
end
end
for
_
,
hash
in
ipairs
(
due
)
do
local
feed_url
=
ctx
.
redis
.
hget
(
'rss:meta:'
..
hash
,
'url'
)
if
not
feed_url
then
-- Orphaned metadata, remove from active set
ctx
.
redis
.
srem
(
'rss:active_feeds'
,
hash
)
else
-- Update last_checked immediately to avoid double-polling
ctx
.
redis
.
hset
(
'rss:meta:'
..
hash
,
'last_checked'
,
tostring
(
now
))
local
ok
,
err
=
pcall
(
function
()
local
body
,
code
=
http
.
get
(
feed_url
)
if
not
body
or
code
~=
200
then
logger
.
warn
(
'RSS: failed to fetch %s (HTTP %s)'
,
feed_url
,
tostring
(
code
))
return
end
local
items
=
parse_feed
(
body
)
if
#
items
==
0
then
return
end
local
feed_title
=
ctx
.
redis
.
hget
(
'rss:meta:'
..
hash
,
'title'
)
or
extract_feed_title
(
body
)
local
seen_key
=
'rss:seen:'
..
hash
-- Check items in reverse order so oldest new items are posted first
local
new_items
=
{}
for
_
,
item
in
ipairs
(
items
)
do
if
item
.
guid
and
item
.
guid
~=
''
then
local
already_seen
=
ctx
.
redis
.
sismember
(
seen_key
,
item
.
guid
)
if
tonumber
(
already_seen
)
==
0
then
table.insert
(
new_items
,
item
)
end
end
end
if
#
new_items
==
0
then
return
end
-- Mark new items as seen
for
_
,
item
in
ipairs
(
new_items
)
do
ctx
.
redis
.
sadd
(
seen_key
,
item
.
guid
)
end
-- Get all subscriber chats
local
subscribers
=
ctx
.
redis
.
smembers
(
'rss:subs:'
..
hash
)
if
not
subscribers
or
#
subscribers
==
0
then
return
end
-- Send new items to all subscribers (oldest first)
for
i
=
#
new_items
,
1
,
-
1
do
local
item
=
new_items
[
i
]
local
text
if
item
.
link
and
item
.
link
~=
''
then
text
=
string.format
(
'<b>%s</b>
\n
<a href="%s">%s</a>'
,
tools
.
escape_html
(
feed_title
),
tools
.
escape_html
(
item
.
link
),
tools
.
escape_html
(
item
.
title
)
)
else
text
=
string.format
(
'<b>%s</b>
\n
%s'
,
tools
.
escape_html
(
feed_title
),
tools
.
escape_html
(
item
.
title
)
)
end
for
_
,
chat_id
in
ipairs
(
subscribers
)
do
local
send_ok
,
send_err
=
pcall
(
api
.
send_message
,
chat_id
,
text
,
{
parse_mode
=
'html'
,
link_preview_options
=
{
is_disabled
=
true
}
}
)
if
not
send_ok
then
logger
.
warn
(
'RSS: failed to send to chat %s: %s'
,
tostring
(
chat_id
),
tostring
(
send_err
))
end
end
end
-- Trim seen set if it grows too large by using a TTL
-- This avoids the data loss issue of rebuilding from current items only
local
seen_count
=
ctx
.
redis
.
scard
(
seen_key
)
if
tonumber
(
seen_count
)
>
MAX_SEEN
*
2
then
-- Set a 7-day TTL on the seen set to let it naturally expire
-- rather than deleting entries that might cause re-posts
ctx
.
redis
.
expire
(
seen_key
,
604800
)
end
end
)
if
not
ok
then
logger
.
error
(
'RSS: error processing feed %s: %s'
,
feed_url
,
tostring
(
err
))
end
end
end
end
return
plugin
File Metadata
Details
Attached
Mime Type
text/html
Expires
Sat, Nov 14, 3:19 PM (2 h, 27 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
133111
Default Alt Text
rss.lua (13 KB)
Attached To
Mode
R69 mattata
Attached
Detach File
Event Timeline