Attachments from XMPP to Telegram require no special configuration.
+
+
For slidgram to bridge attachments from Telegram to XMPP, you have two options:
+
+
HTTP Upload: slidgram will use your XMPP server’s upload component (XEP-0363).
+
No upload: slidgram will copy attachments to a path it can write to. You then need to serve these files from via an HTTP server (eg nginx,
+prosody’s http_files, etc.).
slidgram can use the HTTP Upload component (XEP-0363) of your XMPP server,
+if you configure it with upload-service=upload.example.org (see Generic slidge config).
+In this setting, slidgram will upload files just like any normal user of your server.
modules_enabled={
+-- make sure http_file_share is listed here
+"http_file_share";
+}
+
+Component"upload.example.org""http_file_share"
+-- allow slidgram to use the upload component
+http_file_share_access={"telegram.example.org"}
+
You need to set up no-upload-path to point to a directory, and no-upload-url-prefix to an URL prefix pointing to files in that directory (see Generic slidge config for more detail).
+Example: no-upload-path=/var/www/slidgram-attachments/ and no-upload-url-prefix=https://example.org/slidgram/ means that /var/www/slidgram-attachments/some-image.jpg is accessible at https://example.org/slidgram/some-image.jpg
+
Make sure that no-upload-path is writeable by slidgram and readable by
+your HTTP server. You may use no-upload-file-read-others=true to do that easily,
+but you might want to restrict which users can read this directory.
+
+
Warning
+
slidgram will not take care of removing old files, so you should set up a cronjob,
+a systemd timer, or something similar, to regularly delete files, eg.
+find.-mtime+7-delete&&find.-depth-typed-empty-delete
+to clean up files older than a week.
+
+
For the following examples, in slidgram’s config,
+you would have no-upload-path=/var/lib/slidgram/attachments.
modules_enabled={
+-- make sure http_files is listed here
+"http_files";
+}
+
+-- Must be the same value as slidgram's no-upload-path
+http_files_dir="/var/lib/slidgram/attachments"
+
Here, no-upload-url-prefix would be https://example.org/slidgram/.
+
server{
+listen80;
+server_nameexample.org;
+root/var/www/html;# if you already have nginx serving files…
+
+# the section below is for slidgram
+location/slidgram{
+# Must be the same value as slidgram's no-upload-path
+alias/var/lib/slidgram/attachments/;
+}
+}
+
By default, slidgram uses all config files found in /etc/slidge/conf.d/*.conf.
+You can change this using the SLIDGE_CONF_DIR env var, eg
+SLIDGE_CONF_DIR=/path/dir1:/path/dir2:/path/dir3.
+
We recommend using /etc/slidge/conf.d/common.conf file to set
+the options common to several slidge-based gateways
+(eg, attachment handling, logging options, etc.),
+then use a slidgram-specific dedicated config file, eg
+/etc/slidge/slidgram.conf.
+
Point to this specific file using the -c command line argument when
+launching slidgram.
The following options are for slidge version 0.5.0a2.dev2+g8ca420ef0.
+Depending on how you installed slidgram, you might have a different version of slidge.
+Use slidgram--help for the exact list of options you can use.
+
+
Slidge provides the generic instance-wide options displayed in the table below.
+They may not all have an effect on slidgram’s behaviour.
Maximum number of days for group archive retention.
+
+
port
+
5347
+
The XMPP server’s port for incoming component connections
+
+
server
+
localhost
+
The XMPP server’s host name. Defaults to localhost, which is the standard way of running slidge, on the same host as the XMPP server. The ‘Jabber Component Protocol’ (XEP-0114) does not mention encryption, so you *should* provide encryption another way, eg via port forwarding, if you change this.
+
+
legacy-module
+
unset
+
Importable python module containing (at least) a BaseGateway and a LegacySession subclass. NB: this is not needed if you use a gateway-specific entrypoint, e.g., `slidgram` or `python -m slidgram`.
+
+
home-dir
+
inferred
+
Directory where slidge will writes it persistent data and cache. Defaults to /var/lib/slidge/${SLIDGE_JID}.
+
+
user-jid-validator
+
inferred
+
Regular expression to restrict users that can register to the gateway, by JID. Defaults to .*@${INFERRED_SERVER}. INFERRED_SERVER is derived for the gateway JID, by removing whatever is before the first encountered dot in it. Example: if slidge’s JID=slidge.example.org, INFERRED_SERVER=example.org.
Some legacy network provide ridiculously long filenames, strip above this limit, preserving suffix.
+
+
convert-stickers
+
False
+
Convert lottie vector stickers (from the legacy side) to webp animations.
+
+
fix-filename-suffix-mime-type
+
False
+
Fix the Filename suffix based on the Mime Type of the file. Some clients (eg Conversations) may not inline files that have a wrong suffix for the MIME Type. Therefore the MIME Type of the file is checked, if the suffix is not valid for that MIME Type, a valid one will be picked.
+
+
no-upload-file-read-others
+
False
+
After writing a file in NO_UPLOAD_PATH, change its permission so that ‘others’ can read it.
+
+
no-upload-path
+
None
+
Instead of using the XMPP server’s HTTP upload component, copy files to this dir. You need to set NO_UPLOAD_URL_PREFIX too if you use this option, and configure an web server to serve files in this dir.
Set which JID should request the upload slots. Defaults to the user’s JID if IQ/get privileges granted for the ‘urn:xmpp:http:upload:0’ namespace; the component JID otherwise.
+
+
upload-service
+
None
+
JID of an HTTP upload service the gateway can use. This is optional, as it should be automatically determined via servicediscovery.
+
+
upload-url-prefix
+
None
+
This is an optional setting to make sure the URL of your upload service is never leaked to the legacy network in bodies of messages. This can happen under rare circumstances and/or bugs,when replying to an attachment. Set this to the common prefix of the public URL your attachments get, eg https://upload.example.org:5281/
+
+
use-attachment-original-urls
+
False
+
For legacy plugins in which attachments are publicly downloadable URLs, let XMPP clients directly download them from this URL. Note that this will probably leak your client IP to the legacy network.
This example does not cover the No upload option for attachments (see Configuration).
+For ‘no upload’ with ejabberd, you need an external HTTP server, eg
+Example 2: nginx.
+
+
1listen:
+ 2-
+ 3ip:127.0.0.1
+ 4port:5347
+ 5module:ejabberd_service
+ 6# The next line is required if you're settings up multiple bridges
+ 7global_routes:false
+ 8hosts:
+ 9-"superduper.example.org":
+10password:secret
+11-"other-walled-garden.example.org":
+12password:some-other-secret
+13# repeat for other slidge plugins…
+14
+15# HTTP upload service (XEP-0363)
+16-
+17port:5443
+18module:ejabberd_http
+19tls:true
+20request_handlers:
+21/upload:mod_http_upload
+22
+23acl:
+24slidge_acl:
+25server:
+26-"superduper.example.org"
+27-"other-walled-garden.example.org"
+28# repeat for other slidge plugins you added in the listen section above
+29
+30access_rules:
+31slidge_rule:
+32-allow:slidge_acl
+33
+34modules:
+35mod_http_upload:
+36# A path that ejabberd has Read and Write access to
+37docroot:/ejabberd/upload
+38put_url:"https://@HOST@:5443/upload"
+39access:
+40-allow:local
+41-allow:slidge_acl
+42
+43# for roster auto-fill and "carbons from legacy apps"
+44# (broken in ejabberd when this was written, hopefully fixed since)
+45mod_privilege:
+46roster:
+47both:slidge_rule
+48message:
+49outgoing:slidge_rule
+50iq:
+51"http://jabber.org/protocol/pubsub":
+52both:slidge_rule
+53"http://jabber.org/protocol/pubsub#owner":
+54set:slidge_rule
+55"urn:xmpp:http:upload:0":
+56get:slidge_rule
+57mod_roster:
+58versioning:true
+
dockerruncodeberg.org/slidge/slidgram:latest# works with podman too
+
+
+
Use the :latest tag for the latest release, :vX.X.X for release
+X.X.X, and :main for the bleeding edge.
+
For data persistence, mount a writeable directory into /var/lib/slidge. Inside the container,
+slidgram runs as the slidge user with UID/GID 10000/10000.
If you are not familiar with python packaging, we recommend using pipx to
+set up slidgram and its dependencies, isolated from the rest of your system.
+
# for the latest stable release published to PyPI, if any
+pipxinstallslidgram
+
+slidgram--help
+
If you are using debian you might be interested in installing the
+slidge (unofficial) debian
+package which bundles slidgram
+along with other slidge-based XMPP gateways.
+
Follow the instructions in the repository README. In short:
“XEP-0356: Privileged Entity” only works for XMPP users using the same server as slidgram, e.g., eve@example.org on telegram.example.org.
+
+
Setting up slidgram as a privileged entity (XEP-0356) is optional, but nice to have.
+It improves user experience with a few “cherry on top” features.
+By configuring your XMPP server such that slidgram is a privileged entity, slidgram can:
+
+
automatically add/remove “puppet contacts” from the XMPP roster of slidgram users,
+
reflect on the XMPP side messages sent by users via official Telegram apps,
+
(if using HTTP Upload for attachments from Telegram to XMPP) request upload slots on behalf of slidgram users,
+respecting any quota, retention, permission, or other policy set at the upload component level,
+
synchronize other actions done via official Telegram apps, such as marking messages as read, using emoji reactions,
+retracting messages, sending files…
+
automatically add XMPP bookmarks (XEP-0402) for MUCs (XEP-0045).
In prosody.cfg.lua, add mod_privilege to the modules_enabled list, and
+declare slidgram privileges in the appropriate virtualhost block:
+
local_privileges={
+roster="both";-- for adding/removing contacts from the users' rosters
+message="outgoing";-- for reflecting messages sent by the user themselve from official Telegram apps
+iq={
+["http://jabber.org/protocol/pubsub"]="both";-- for PEP Bookmarks
+["http://jabber.org/protocol/pubsub#owner"]="set";-- for Message Display Synchronization
+["urn:xmpp:http:upload:0"]="get";-- for HTTP Upload on behalf of users
+}
+};
+
+VirtualHost"example.org"
+privileged_entities={
+["telegram.example.org"]=_privileges;
+}
+
+Component"telegram.example.org"
+modules_enabled={"privilege"}
+
+
+
Then either restart the prosody server, or reload the config.
+You might need to use
+mod_reload_component,
+and activate/deactivate hosts
+for all changes to be taken into account
+(restarting prosody is the easiest way to go).
slidgram uses the Jabber Component Protocol to
+connect to an XMPP server.
+slidgram itself needs to have a JID without a local part, such as telegram.example.org.
+In a typical deployment, slidgram runs on the same host as the XMPP server and connects to it via localhost.
+This requires adequate configuration of the XMPP server, and depends on the XMPP server software you are using.
+
This documentation explains how to do that for
+prosody
+and ejabberd.
+It might be outdated and you may want to check the official, up-to-date documentation of the XMPP server you are using.
+If you know how to set up slidge with other XMPP servers, please contribute to the docs. ;-)
We are really happy to welcome new contributors to slidgram!
+Before starting anything, please join our chat room
+to say hi and see if anyone is already working on the bug you want to fix or
+the feature you want to implement.
The easiest way to achieve 2 and 3 at the same time is to use the provided
+docker-compose.yml file. Run dockercomposeup (or podman-composeup)
+in the directory of the repository you just cloned. It will:
+
+
spin up a prosody instance configured for development;
+
launch slidgram in a dedicated container, with hot code reload.
+
+
You will then be able to connect to that prosody using any XMPP client, using
+“test@localhost” as JID and “password” as password. We recommended
+gajim which lets you start a different profile than your
+main profile: gajim-pslidge-c~/.local/share/gajim-slidge.
+
To avoid having to accept self-signed certificates, you can add prosody’s
+certificate to your local store. In debian, you can do that with:
+
# download the certificate
+curlhttps://codeberg.org/slidge/prosody-dev-container/raw/branch/main/localhost.crt|sudotee/usr/local/share/ca-certificates/localhost.crt
+# set the right perms
+sudochmod600/usr/local/share/ca-certificates/localhost.crt
+# import it
+sudoupdate-ca-certificates
+
In some situations, developing in containers is not optimal, for instance if
+you want to attach an interactive debugger to the slidgram process.
+
slidgram defines its dependencies in a PEP 517-compliant
+pyproject.toml file. This means that you can use different tools to set up a
+virtualenv with the appropriate dependencies.
+
We recommend using uv which is fast and has some
+nice features. By running uvsync--frozen--all-groups--all-extras, a
+standard virtualenv will be installed in ./.venv. You can then activate it
+with source.venv/bin/activate and launch slidgram--help.
+
NB: you will need to set up a local XMPP server. An easy way to do that is to
+use the slidge-prosody-dev container: dockerrun--networkhostcodeberg.org/slidge/prosody-slidge-dev.
+With it you will be able to launch slidgram with
+slidgram--jidslidge.localhost--secretsecret--debug--home-dir./persistent.
Maybe you will discover that what you want to change, add or fix is not part
+of slidgram but part of slidge core. To modify slidge core, first you
+need to clone the slidge repo somewhere
+on your computer.
+
Let’s assume you cloned slidge in the same root dir as slidgram,
+e.g., ~/src/slidgram and ~/src/slidge.
+With the docker-compose-based dev setup, you will need to add an additional
+mount for the slidge dir, e.g., ../slidge:/build/slidge. If you opted for
+the virtualenv solution, you can install slidge in your virtualenv in editable
+mode with [uv]pipinstall-e../slidge.
Commit messages should be in the form of
+conventional commits, with
+some additional types defined in ./commitlinx.config.js.
+This makes it possible to automatically generate changelogs on releases, it
+is worth it! We use git-cliff for this. You don’t have
+to install git-cliff locally, the magic happens in CI.
+
We recommended setting up pre-commit to ensure that
+these pass for each commit: pre-commitinstall&&pre-commitinstall--hook-typecommit-msg.
+
Make a new git branch, commit your changes, push it to your fork and open a
+pull request!
+
NB: we also accept contributions without pull requests. Just push your changes
+somewhere and tell us where to pull via the group chat.
Only displayed markers have an equivalence in Telegram. They are also used to sync read state from and to Telegram clients. In groups, you only receive displayed markers for message you have sent, not for messages from other participants.
Incoming telegram messages formatting are converted to XEP-0393 styling, with linked URLs shown. Incoming XMPP messages are parsed for message styling tags and converted to telegram formatted text. Additionally, double pipes can be used for spoilers, eg ||spoiler||.
To start using a slidgram, you need to register your XMPP account with it.
+The instance has a unique XMPP (aka JID) address, which is just a domain name (e.g., telegram.example.org, without any @ sign in it).
+
There are two ways to register, depending on which XMPP/Jabber client you are using:
+
+
If your client supports adhoc commands (such as Movim, Gajim, or Cheogram),
+you can use the “Register” command with the slidgram instance’s
+address.
+
If your client does not support adhoc commands (such as Conversations), you
+can register by sending a text message that says “register” to the
+slidgram instance’s address.
Contact JIDs are of the form 123456789@slidge-telegram.example.org where 123456789 is a telegram ID.
+If you want to find the telegram ID of someone using their phone number, use slidge’s search feature:
+Finding legacy contacts.
Your contacts’ puppet JIDs presence statuses will show when they were last seen online,
+and their presence statuses will be set to “away” after 5 minutes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/mod_privilege.lua b/modules/mod_privilege.lua
new file mode 100644
index 0000000..22d9486
--- /dev/null
+++ b/modules/mod_privilege.lua
@@ -0,0 +1,685 @@
+-- XEP-0356 (Privileged Entity)
+-- Copyright (C) 2015-2022 Jérôme Poisson
+--
+-- This module is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- Some parts come from mod_remote_roster (module by Waqas Hussain and Kim Alvefur, see https://code.google.com/p/prosody-modules/)
+
+-- TODO: manage external (for "roster" presence permission) when the account with the roster is offline
+
+local jid = require("util.jid")
+local set = require("util.set")
+local st = require("util.stanza")
+local roster_manager = require("core.rostermanager")
+local usermanager_user_exists = require "core.usermanager".user_exists
+local hosts = prosody.hosts
+local full_sessions = prosody.full_sessions
+
+local priv_session = module:shared("/*/privilege/session")
+
+if priv_session.connected_cb == nil then
+ -- set used to have connected event listeners
+ -- which allows a host to react on events from
+ -- other hosts
+ priv_session.connected_cb = set.new()
+end
+local connected_cb = priv_session.connected_cb
+
+-- the folowing sets are used to forward presence stanza
+-- the folowing sets are used to forward presence stanza
+local presence_man_ent = set.new()
+local presence_roster = set.new()
+
+local _ALLOWED_ROSTER = set.new({'none', 'get', 'set', 'both'})
+local _ROSTER_GET_PERM = set.new({'get', 'both'})
+local _ROSTER_SET_PERM = set.new({'set', 'both'})
+local _ALLOWED_MESSAGE = set.new({'none', 'outgoing'})
+local _ALLOWED_PRESENCE = set.new({'none', 'managed_entity', 'roster'})
+local _PRESENCE_MANAGED = set.new({'managed_entity', 'roster'})
+local _TO_CHECK = {roster=_ALLOWED_ROSTER, message=_ALLOWED_MESSAGE, presence=_ALLOWED_PRESENCE}
+local _PRIV_ENT_NS = 'urn:xmpp:privilege:2'
+local _FORWARDED_NS = 'urn:xmpp:forward:0'
+local _MODULE_HOST = module:get_host()
+
+
+module:log("debug", "Loading privileged entity module ")
+
+
+--> Permissions management <--
+
+local config_priv = module:get_option("privileged_entities", {})
+
+local function get_session_privileges(session, host)
+ if not session.privileges then return nil end
+ return session.privileges[host]
+end
+
+
+local function advertise_perm(session, to_jid, perms)
+ -- send stanza to advertise permissions
+ -- as expained in § 4.2
+ local message = st.message({from=module.host, to=to_jid})
+ :tag("privilege", {xmlns=_PRIV_ENT_NS})
+
+ for _, perm in pairs({'roster', 'message', 'presence'}) do
+ if perms[perm] then
+ message:tag("perm", {access=perm, type=perms[perm]}):up()
+ end
+ end
+ local iq_perm = perms["iq"]
+ if iq_perm ~= nil then
+ local perm_el = st.stanza("perm", {access="iq"})
+ for namespace, ns_perm in pairs(iq_perm) do
+ local perm_type
+ if ns_perm.set and ns_perm.get then
+ perm_type = "both"
+ elseif ns_perm.set then
+ perm_type = "set"
+ elseif ns_perm.get then
+ perm_type = "get"
+ else
+ perm_type = nil
+ end
+ perm_el:tag("namespace", {ns=namespace, type=perm_type}):up()
+ end
+ message:add_child(perm_el)
+ end
+ session.send(message)
+end
+
+local function set_presence_perm_set(to_jid, perms)
+ -- fill the presence sets according to perms
+ if _PRESENCE_MANAGED:contains(perms.presence) then
+ presence_man_ent:add(to_jid)
+ end
+ if perms.presence == 'roster' then
+ presence_roster:add(to_jid)
+ end
+end
+
+local function advertise_presences(session, to_jid, perms)
+ -- send presence status for already connected entities
+ -- as explained in § 7.1
+ -- people in roster are probed only for active sessions
+ -- TODO: manage roster load for inactive sessions
+ if not perms.presence then return; end
+ local to_probe = {}
+ for _, user_session in pairs(full_sessions) do
+ if user_session.presence and _PRESENCE_MANAGED:contains(perms.presence) then
+ local presence = st.clone(user_session.presence)
+ presence.attr.to = to_jid
+ module:log("debug", "sending current presence for "..tostring(user_session.full_jid))
+ session.send(presence)
+ end
+ if perms.presence == "roster" then
+ -- we reset the cache to avoid to miss a presence that just changed
+ priv_session.last_presence = nil
+
+ if user_session.roster then
+ local bare_jid = jid.bare(user_session.full_jid)
+ for entity, item in pairs(user_session.roster) do
+ if entity~=false and entity~="pending" and (item.subscription=="both" or item.subscription=="to") then
+ local _, host = jid.split(entity)
+ if not hosts[host] then -- we don't probe jid from hosts we manage
+ -- using a table with entity as key avoid probing several time the same one
+ to_probe[entity] = bare_jid
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- now we probe peoples for "roster" presence permission
+ for probe_to, probe_from in pairs(to_probe) do
+ module:log("debug", "probing presence for %s (on behalf of %s)", tostring(probe_to), tostring(probe_from))
+ local probe = st.presence({from=probe_from, to=probe_to, type="probe"})
+ prosody.core_route_stanza(nil, probe)
+ end
+end
+
+
+local function on_auth(event)
+ -- Check if entity is privileged according to configuration,
+ -- and set session.privileges accordingly
+
+ local session = event.session
+ local bare_jid = jid.join(session.username, session.host)
+ if not session.privileges then
+ session.privileges = {}
+ end
+
+ local conf_ent_priv = config_priv[bare_jid]
+ local ent_priv = {}
+ if conf_ent_priv ~= nil then
+ module:log("debug", "Entity is privileged")
+ for perm_type, allowed_values in pairs(_TO_CHECK) do
+ local value = conf_ent_priv[perm_type]
+ if value ~= nil then
+ if not allowed_values:contains(value) then
+ module:log('warn', 'Invalid value for '..perm_type..' privilege: ['..value..']')
+ module:log('warn', 'Setting '..perm_type..' privilege to none')
+ ent_priv[perm_type] = nil
+ elseif value == 'none' then
+ ent_priv[perm_type] = nil
+ else
+ ent_priv[perm_type] = value
+ end
+ else
+ ent_priv[perm_type] = nil
+ end
+ end
+ -- extra checks for presence permission
+ if ent_priv.presence == 'roster' and not _ROSTER_GET_PERM:contains(ent_priv.roster) then
+ module:log("warn", "Can't allow roster presence privilege without roster \"get\" privilege")
+ module:log("warn", "Setting presence permission to none")
+ ent_priv.presence = nil
+ end
+ -- iq permission
+ local iq_perm_config = conf_ent_priv["iq"]
+ if iq_perm_config ~= nil then
+ local iq_perm = {}
+ ent_priv["iq"] = iq_perm
+ for ns, ns_perm_config in pairs(iq_perm_config) do
+ iq_perm[ns] = {
+ ["get"] = ns_perm_config == "get" or ns_perm_config == "both",
+ ["set"] = ns_perm_config == "set" or ns_perm_config == "both"
+ }
+ end
+ else
+ ent_priv["iq"] = nil
+ end
+
+ if session.type == "component" then
+ -- we send the message stanza only for component
+ -- it will be sent at first for other entities
+ advertise_perm(session, bare_jid, ent_priv)
+ set_presence_perm_set(bare_jid, ent_priv)
+ advertise_presences(session, bare_jid, ent_priv)
+ end
+ end
+
+ session.privileges[_MODULE_HOST] = ent_priv
+end
+
+local function on_presence(event)
+ -- Permission are already checked at this point,
+ -- we only advertise them to the entity
+ local session = event.origin
+ local session_privileges = get_session_privileges(session, _MODULE_HOST)
+ if session_privileges then
+ advertise_perm(session, session.full_jid, session_privileges)
+ set_presence_perm_set(session.full_jid, session_privileges)
+ advertise_presences(session, session.full_jid, session_privileges)
+ end
+end
+
+local function on_component_auth(event)
+ -- react to component-authenticated event from this host
+ -- and call the on_auth methods from all other hosts
+ -- needed for the component to get delegations advertising
+ for callback in connected_cb:items() do
+ callback(event)
+ end
+end
+
+if module:get_host_type() ~= "component" then
+ connected_cb:add(on_auth)
+end
+module:hook('authentication-success', on_auth)
+module:hook('component-authenticated', on_component_auth)
+module:hook('presence/initial', on_presence)
+
+
+--> roster permission <--
+
+-- get
+module:hook("iq-get/bare/jabber:iq:roster:query", function(event)
+ local session, stanza = event.origin, event.stanza
+ if not stanza.attr.to then
+ -- we don't want stanzas addressed to /self
+ return
+ end
+ local node, host = jid.split(stanza.attr.to)
+ local session_privileges = get_session_privileges(session, host)
+
+ if session_privileges and _ROSTER_GET_PERM:contains(session_privileges.roster) then
+ module:log("debug", "Roster get from allowed privileged entity received")
+ -- following code is adapted from mod_remote_roster
+ local roster = roster_manager.load_roster(node, host)
+
+ local reply = st.reply(stanza):query("jabber:iq:roster")
+ for entity_jid, item in pairs(roster) do
+ if entity_jid and entity_jid ~= "pending" then
+ reply:tag("item", {
+ jid = entity_jid,
+ subscription = item.subscription,
+ ask = item.ask,
+ name = item.name,
+ })
+ for group in pairs(item.groups) do
+ reply:tag("group"):text(group):up()
+ end
+ reply:up(); -- move out from item
+ end
+ end
+ -- end of code adapted from mod_remote_roster
+ session.send(reply)
+ else
+ module:log("warn", "Entity "..tostring(session.full_jid).." try to get roster without permission")
+ session.send(st.error_reply(stanza, 'auth', 'forbidden'))
+ end
+
+ return true
+end)
+
+-- set
+module:hook("iq-set/bare/jabber:iq:roster:query", function(event)
+ local session, stanza = event.origin, event.stanza
+ if not stanza.attr.to then
+ -- we don't want stanzas addressed to /self
+ return
+ end
+ local from_node, from_host = jid.split(stanza.attr.to)
+ local session_privileges = get_session_privileges(session, from_host)
+
+ if session_privileges and _ROSTER_SET_PERM:contains(session_privileges.roster) then
+ module:log("debug", "Roster set from allowed privileged entity received")
+ -- following code is adapted from mod_remote_roster
+ if not(usermanager_user_exists(from_node, from_host)) then return; end
+ local roster = roster_manager.load_roster(from_node, from_host)
+ if not(roster) then return; end
+
+ local query = stanza.tags[1]
+ for _, item in ipairs(query.tags) do
+ if item.name == "item"
+ and item.attr.xmlns == "jabber:iq:roster" and item.attr.jid
+ -- Protection against overwriting roster.pending, until we move it
+ and item.attr.jid ~= "pending" then
+
+ local item_jid = jid.prep(item.attr.jid)
+ local _, host, resource = jid.split(item_jid)
+ if not resource then
+ if item_jid ~= stanza.attr.to then -- not self-item_jid
+ if item.attr.subscription == "remove" then
+ local r_item = roster[item_jid]
+ if r_item then
+ roster[item_jid] = nil
+ if roster_manager.save_roster(from_node, from_host, roster) then
+ session.send(st.reply(stanza))
+ roster_manager.roster_push(from_node, from_host, item_jid)
+ else
+ roster[item_jid] = item
+ session.send(st.error_reply(stanza, "wait", "internal-server-error", "Unable to save roster"))
+ end
+ else
+ session.send(st.error_reply(stanza, "modify", "item-not-found"))
+ end
+ else
+ local subscription = item.attr.subscription
+ if subscription ~= "both" and subscription ~= "to" and subscription ~= "from" and subscription ~= "none" then -- TODO error on invalid
+ subscription = roster[item_jid] and roster[item_jid].subscription or "none"
+ end
+ local r_item = {name = item.attr.name, groups = {}}
+ if r_item.name == "" then r_item.name = nil; end
+ r_item.subscription = subscription
+ if subscription ~= "both" and subscription ~= "to" then
+ r_item.ask = roster[item_jid] and roster[item_jid].ask
+ end
+ for _, child in ipairs(item) do
+ if child.name == "group" then
+ local text = table.concat(child)
+ if text and text ~= "" then
+ r_item.groups[text] = true
+ end
+ end
+ end
+ local olditem = roster[item_jid]
+ roster[item_jid] = r_item
+ if roster_manager.save_roster(from_node, from_host, roster) then -- Ok, send success
+ session.send(st.reply(stanza))
+ -- and push change to all resources
+ roster_manager.roster_push(from_node, from_host, item_jid)
+ else -- Adding to roster failed
+ roster[item_jid] = olditem
+ session.send(st.error_reply(stanza, "wait", "internal-server-error", "Unable to save roster"))
+ end
+ end
+ else -- Trying to add self to roster
+ session.send(st.error_reply(stanza, "cancel", "not-allowed"))
+ end
+ else -- Invalid JID added to roster
+ module:log("warn", "resource: %s , host: %s", tostring(resource), tostring(host))
+ session.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME what's the correct error?
+ end
+ else -- Roster set didn't include a single item, or its name wasn't 'item'
+ session.send(st.error_reply(stanza, "modify", "bad-request"))
+ end
+ end -- for loop end
+ -- end of code adapted from mod_remote_roster
+ else -- The permission is not granted
+ module:log("warn", "Entity "..tostring(session.full_jid).." try to set roster without permission")
+ session.send(st.error_reply(stanza, 'auth', 'forbidden'))
+ end
+
+ return true
+end)
+
+
+--> message permission <--
+
+local function clean_xmlns(node)
+ -- Recursively remove "jabber:client" attribute from node.
+ -- In Prosody internal routing, xmlns should not be set.
+ -- Keeping xmlns would lead to issues like mod_smacks ignoring the outgoing stanza,
+ -- so we remove all xmlns attributes with a value of "jabber:client"
+ if node.attr.xmlns == 'jabber:client' then
+ for childnode in node:childtags() do
+ clean_xmlns(childnode)
+ end
+ node.attr.xmlns = nil
+ end
+end
+
+module:hook("message/host", function(event)
+ local session, stanza = event.origin, event.stanza
+ local privilege_elt = stanza:get_child('privilege', _PRIV_ENT_NS)
+ if privilege_elt==nil then return; end
+ local _, to_host = jid.split(stanza.attr.to)
+ local session_privileges = get_session_privileges(session, to_host)
+
+ if session_privileges and session_privileges.message=="outgoing" then
+ if #privilege_elt.tags==1 and privilege_elt.tags[1].name == "forwarded"
+ and privilege_elt.tags[1].attr.xmlns==_FORWARDED_NS then
+ local message_elt = privilege_elt.tags[1]:get_child('message', 'jabber:client')
+ if message_elt ~= nil then
+ local username, from_host, from_resource = jid.split(message_elt.attr.from)
+ if from_resource == nil and hosts[from_host] then -- we only accept bare jids from one of the server hosts
+ clean_xmlns(message_elt); -- needed do to proper routing
+ local session = {
+ username = username;
+ host = from_host;
+ type = "c2s";
+ log = module._log;
+ }
+ -- at this point everything should be alright, we can send the message
+ prosody.core_post_stanza(session, message_elt, true)
+ else -- trying to send a message from a forbidden entity
+ module:log("warn", "Entity "..tostring(session.full_jid).." try to send a message from "..tostring(message_elt.attr.from))
+ session.send(st.error_reply(stanza, 'auth', 'forbidden'))
+ end
+ else -- incorrect message child
+ session.send(st.error_reply(stanza, "modify", "bad-request", "invalid forwarded element"))
+ end
+ else -- incorrect forwarded child
+ session.send(st.error_reply(stanza, "modify", "bad-request", "invalid element"))
+ end
+ else -- The permission is not granted
+ module:log("warn", "Entity "..tostring(session.full_jid).." try to send message without permission")
+ session.send(st.error_reply(stanza, 'auth', 'forbidden'))
+ end
+
+ return true
+end)
+
+
+--> presence permission <--
+
+local function same_tags(tag1, tag2)
+ -- check if two tags are equivalent
+
+ if tag1.name ~= tag2.name then return false; end
+
+ if #tag1 ~= #tag2 then return false; end
+
+ for name, value in pairs(tag1.attr) do
+ if tag2.attr[name] ~= value then return false; end
+ end
+
+ for i=1,#tag1 do
+ if type(tag1[i]) == "string" then
+ if tag1[i] ~= tag2[i] then return false; end
+ else
+ if not same_tags(tag1[i], tag2[i]) then return false; end
+ end
+ end
+
+ return true
+end
+
+local function same_presences(presence1, presence2)
+ -- check that 2 stanzas are equivalent (except for "to" attribute)
+ -- /!\ if the id change but everything else is equivalent, this method return false
+ -- this behaviour may change in the future
+ if presence1.attr.from ~= presence2.attr.from or presence1.attr.id ~= presence2.attr.id
+ or presence1.attr.type ~= presence2.attr.type then
+ return false
+ end
+
+ if presence1.attr.id and presence1.attr.id == presence2.attr.id then return true; end
+
+ if #presence1 ~= #presence2 then return false; end
+
+ for i=1,#presence1 do
+ if type(presence1[i]) == "string" then
+ if presence1[i] ~= presence2[i] then return false; end
+ else
+ if not same_tags(presence1[i], presence2[i]) then return false; end
+ end
+ end
+
+ return true
+end
+
+local function forward_presence(presence, to_jid)
+ local presence_fwd = st.clone(presence)
+ presence_fwd.attr.to = to_jid
+ module:log("debug", "presence forwarded to "..to_jid..": "..tostring(presence_fwd))
+ module:send(presence_fwd)
+ -- cache used to avoid to send several times the same stanza
+ priv_session.last_presence = presence
+end
+
+module:hook("presence/bare", function(event)
+ if presence_man_ent:empty() and presence_roster:empty() then return; end
+
+ local stanza = event.stanza
+ if stanza.attr.type == nil or stanza.attr.type == "unavailable" then
+ if not stanza.attr.to then
+ for entity in presence_man_ent:items() do
+ if stanza.attr.from ~= entity then forward_presence(stanza, entity); end
+ end
+ else -- directed presence
+ -- we ignore directed presences from our own host, as we already have them
+ local _, from_host = jid.split(stanza.attr.from)
+ if hosts[from_host] then return; end
+
+ -- we don't send several time the same presence, as recommended in §7 #2
+ if priv_session.last_presence and same_presences(priv_session.last_presence, stanza) then
+ return
+ end
+
+ for entity in presence_roster:items() do
+ if stanza.attr.from ~= entity then forward_presence(stanza, entity); end
+ end
+ end
+ end
+end, 150)
+
+--> IQ permission <--
+
+module:hook("iq/bare/".._PRIV_ENT_NS..":privileged_iq", function(event)
+ local session, stanza = event.origin, event.stanza
+ if not stanza.attr.to then
+ -- we don't want stanzas addressed to /self
+ return
+ end
+ local from_node, from_host, from_resource = jid.split(stanza.attr.to)
+
+ if from_resource ~= nil or not usermanager_user_exists(from_node, from_host) then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ "wrapping stanza recipient must be a bare JID of a local user"
+ )
+ )
+ return true
+ end
+
+ local session_privileges = get_session_privileges(session, from_host)
+
+ if session_privileges == nil then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ "no privilege granted"
+ )
+ )
+ return true
+ end
+
+ local iq_privileges = session_privileges["iq"]
+ if iq_privileges == nil then
+ session.send(
+ session.send(st.error_reply(stanza, "auth", "forbidden", "you are not allowed to send privileged stanzas"))
+ )
+ return true
+ end
+
+ local privileged_iq = stanza:get_child("privileged_iq", _PRIV_ENT_NS)
+
+ local wrapped_iq = privileged_iq.tags[1]
+ if wrapped_iq == nil then
+ session.send(
+ st.error_reply(stanza, "auth", "forbidden", "missing stanza to send")
+ )
+ return true
+ end
+
+ if wrapped_iq.attr.xmlns ~= "jabber:client" then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ 'wrapped must have a xmlns of "jabber:client"'
+ )
+ )
+ return true
+ end
+
+ clean_xmlns(wrapped_iq)
+
+ if #wrapped_iq.tags ~= 1 then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ 'invalid payload in wrapped '
+ )
+ )
+ return true
+ end
+
+ local payload = wrapped_iq.tags[1]
+
+ local priv_ns = payload.attr.xmlns
+ if priv_ns == nil then
+ session.send(
+ st.error_reply(stanza, "auth", "forbidden", "xmlns not set in privileged ")
+ )
+ return true
+ end
+
+ local ns_perms = iq_privileges[priv_ns]
+ local iq_type = stanza.attr.type
+ if ns_perms == nil or iq_type == nil or not ns_perms[iq_type] then
+ session.send(
+ session.send(st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ "you are not allowed to send privileged stanzas of this type and namespace")
+ )
+ )
+ return true
+ end
+
+ if wrapped_iq.attr.from ~= nil and wrapped_iq.attr.from ~= stanza.attr.to then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ 'wrapped "from" attribute is inconsistent with main "to" attribute'
+ )
+ )
+ return true
+ end
+
+ wrapped_iq.attr.from = stanza.attr.to
+
+
+ if wrapped_iq.attr.type ~= iq_type then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ 'invalid wrapped : type mismatch'
+ )
+ )
+ return true
+ end
+
+ if wrapped_iq.attr.id == nil then
+ session.send(
+ st.error_reply(
+ stanza,
+ "auth",
+ "forbidden",
+ 'invalid wrapped : missing "id" attribute'
+ )
+ )
+ return true
+ end
+
+ -- at this point, wrapped_iq is considered valid, and privileged entity is allowed to send it
+ local username, from_host, _ = jid.split(wrapped_iq.attr.from)
+ local newsession = {
+ username = username;
+ host = from_host;
+ full_jid = stanza.attr.to;
+ type = "c2s";
+ log = module._log;
+ }
+
+ module:send_iq(wrapped_iq,newsession)
+ :next(function (response)
+ local reply = st.reply(stanza);
+ response.stanza.attr.xmlns = 'jabber:client'
+ reply:tag("privilege", {xmlns = _PRIV_ENT_NS})
+ :tag("forwarded", {xmlns = _FORWARDED_NS})
+ :add_child(response.stanza)
+ reply.attr.type = response.stanza.attr.type;
+ session.send(reply)
+ end,
+ function(response)
+ module:log("error", "Error while sending privileged : %s", response);
+ session.send(
+ st.error_reply(
+ stanza,
+ "cancel",
+ "internal-server-error"
+ )
+ )
+ end)
+
+ return true
+end)
diff --git a/slidgram/Dockerfile b/slidgram/Dockerfile
new file mode 100644
index 0000000..e642ef1
--- /dev/null
+++ b/slidgram/Dockerfile
@@ -0,0 +1,11 @@
+# Кастомный образ slidgram с поддержкой SOCKS5-прокси для маршрутизации Telegram
+# через VPS01 (SSH-туннель SOCKS5 172.27.0.1:1080, вне РФ).
+# Базовый: codeberg.org/slidge/slidgram:latest
+# Примечание: pysocks (модуль `socks`) УЖЕ есть в базовом образе (PySocks-1.7.1).
+
+FROM codeberg.org/slidge/slidgram:latest
+
+# Патч: добавить поддержку прокси в slidgram
+COPY patch-telegram.py /tmp/patch-telegram.py
+COPY patch-gateway.py /tmp/patch-gateway.py
+RUN python /tmp/patch-telegram.py && python /tmp/patch-gateway.py
\ No newline at end of file
diff --git a/slidgram/patch-gateway.py b/slidgram/patch-gateway.py
new file mode 100644
index 0000000..952026c
--- /dev/null
+++ b/slidgram/patch-gateway.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Патч slidgram/gateway.py: передать SOCKS5-прокси из env SLIDGRAM_PROXY в Client при регистрации."""
+from pathlib import Path
+
+p = Path("/venv/lib/python3.13/site-packages/slidgram/gateway.py")
+src = p.read_text()
+
+old = ''' tg_client = Client(
+ str(user_jid.bare),
+ phone_number=phone,
+ api_id=registration_form.get("api_id") or config.API_ID,
+ api_hash=registration_form.get("api_hash") or config.API_HASH,
+ workdir=global_config.HOME_DIR,
+ )
+'''
+new = ''' _proxy = None
+ _proxy_url = __import__("os").environ.get("SLIDGRAM_PROXY", "")
+ if _proxy_url and _proxy_url.startswith("socks5://"):
+ _h = _proxy_url.split("://", 1)[1]
+ _host, _, _port = _h.partition(":")
+ _proxy = dict(scheme="socks5", hostname=_host, port=int(_port))
+ tg_client = Client(
+ str(user_jid.bare),
+ phone_number=phone,
+ api_id=registration_form.get("api_id") or config.API_ID,
+ api_hash=registration_form.get("api_hash") or config.API_HASH,
+ workdir=global_config.HOME_DIR,
+ proxy=_proxy,
+ )
+'''
+assert old in src, "gateway.py: шаблон не найден"
+p.write_text(src.replace(old, new, 1))
+print("gateway.py patched OK")
\ No newline at end of file
diff --git a/slidgram/patch-telegram.py b/slidgram/patch-telegram.py
new file mode 100644
index 0000000..bc324fd
--- /dev/null
+++ b/slidgram/patch-telegram.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+"""Патч slidgram/telegram.py: прокинуть SOCKS5-прокси из env SLIDGRAM_PROXY в Client."""
+import re
+from pathlib import Path
+
+p = Path("/venv/lib/python3.13/site-packages/slidgram/telegram.py")
+src = p.read_text()
+
+old = ''' def __init__(self, name: str) -> None:
+ super().__init__(name, workdir=str(global_config.HOME_DIR))
+'''
+new = ''' def __init__(self, name: str) -> None:
+ import os
+ _proxy = None
+ _proxy_url = os.environ.get("SLIDGRAM_PROXY", "")
+ if _proxy_url and _proxy_url.startswith("socks5://"):
+ _h = _proxy_url.split("://", 1)[1]
+ _host, _, _port = _h.partition(":")
+ _proxy = dict(scheme="socks5", hostname=_host, port=int(_port))
+ super().__init__(name, workdir=str(global_config.HOME_DIR), proxy=_proxy)
+'''
+assert old in src, "telegram.py: шаблон не найден"
+p.write_text(src.replace(old, new, 1))
+print("telegram.py patched OK")
\ No newline at end of file