Page MenuHomePhabricator (Chris)

No OneTemporary

Authored By
Unknown
Size
311 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/Dockerfile b/Dockerfile
index 44bcebd..6b00e07 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,65 +1,68 @@
# mattata v2.0 - Multi-stage Docker build
# Builder stage: compile Lua and dependencies
FROM ubuntu:22.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
build-essential \
wget \
unzip \
libreadline-dev \
libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Lua 5.3
RUN cd /tmp && \
wget -q https://www.lua.org/ftp/lua-5.3.6.tar.gz && \
tar xzf lua-5.3.6.tar.gz && \
cd lua-5.3.6 && \
make linux && \
make install && \
cd / && rm -rf /tmp/lua-5.3.6*
# Install LuaRocks
RUN cd /tmp && \
wget -q https://luarocks.org/releases/luarocks-3.9.2.tar.gz && \
tar xzf luarocks-3.9.2.tar.gz && \
cd luarocks-3.9.2 && \
./configure --with-lua=/usr/local && \
make && make install && \
cd / && rm -rf /tmp/luarocks-3.9.2*
-# Install Lua dependencies
-RUN luarocks install telegram-bot-lua && \
- luarocks install pgmoon && \
+# Install telegram-bot-lua v3.0 from source (v3.0 not yet published to luarocks)
+RUN cd /tmp && \
+ git clone --depth 1 https://github.com/wrxck/telegram-bot-lua.git && \
+ cd telegram-bot-lua && \
+ luarocks make telegram-bot-lua-3.0-0.rockspec && \
+ cd / && rm -rf /tmp/telegram-bot-lua
+
+# Install remaining Lua dependencies
+RUN luarocks install pgmoon && \
luarocks install redis-lua && \
- luarocks install dkjson && \
- luarocks install luasocket && \
- luarocks install luasec && \
- luarocks install luautf8
+ luarocks install luaossl
# Runtime stage
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
libreadline8 \
libssl3 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy Lua installation from builder
COPY --from=builder /usr/local /usr/local
# Set up app directory
WORKDIR /app
COPY . /app
# Run as non-root
RUN useradd -m mattata
USER mattata
CMD ["lua", "main.lua"]
diff --git a/spec/core/permissions_spec.lua b/spec/core/permissions_spec.lua
index 9400e5c..91af8a2 100644
--- a/spec/core/permissions_spec.lua
+++ b/spec/core/permissions_spec.lua
@@ -1,250 +1,249 @@
--[[
Tests for src/core/permissions.lua
Tests is_global_admin, is_group_admin (with cache), check_bot_can,
can_restrict, can_delete, can_pin, is_admin_or_mod.
]]
describe('core.permissions', function()
local permissions
local mock_api_mod = require('spec.helpers.mock_api')
local mock_redis = require('spec.helpers.mock_redis')
local mock_db = require('spec.helpers.mock_db')
local api, redis, db
before_each(function()
-- Reset modules to get fresh state
package.loaded['src.core.permissions'] = nil
package.loaded['src.core.session'] = nil
package.loaded['src.core.config'] = nil
-- Mock config to return known admin list
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
get_number = function(key, default) return default end,
is_enabled = function(key) return false end,
bot_admins = function() return { 221714512, 99999 } end,
bot_name = function() return 'mattata' end,
get_list = function(key) return { 221714512, 99999 } end,
load = function() end,
VERSION = '2.0',
}
redis = mock_redis.new()
-- Init session with our mock redis
local session = require('src.core.session')
session.init(redis)
permissions = require('src.core.permissions')
api = mock_api_mod.new()
db = mock_db.new()
end)
after_each(function()
api.reset()
redis.reset()
db.reset()
end)
describe('is_global_admin()', function()
it('should return true for a global admin', function()
assert.is_true(permissions.is_global_admin(221714512))
end)
it('should return true for second global admin', function()
assert.is_true(permissions.is_global_admin(99999))
end)
it('should return false for non-admin', function()
assert.is_false(permissions.is_global_admin(111111))
end)
it('should return false for nil', function()
assert.is_false(permissions.is_global_admin(nil))
end)
it('should handle string user_id by converting to number', function()
assert.is_true(permissions.is_global_admin('221714512'))
end)
it('should return false for non-numeric string', function()
assert.is_false(permissions.is_global_admin('abc'))
end)
end)
describe('is_group_admin()', function()
it('should return true for global admin without API call', function()
local result = permissions.is_group_admin(api, -100123, 221714512)
assert.is_true(result)
-- Should not have called get_chat_member since user is global admin
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should return true for Telegram administrator', function()
api.set_admin(-100123, 456)
local result = permissions.is_group_admin(api, -100123, 456)
assert.is_true(result)
end)
it('should return true for chat creator', function()
api.set_creator(-100123, 789)
local result = permissions.is_group_admin(api, -100123, 789)
assert.is_true(result)
end)
it('should return false for regular member', function()
local result = permissions.is_group_admin(api, -100123, 111)
assert.is_false(result)
end)
it('should cache admin status in session', function()
api.set_admin(-100123, 456)
permissions.is_group_admin(api, -100123, 456)
-- Second call should use cache, not API
api.reset()
local result = permissions.is_group_admin(api, -100123, 456)
assert.is_true(result)
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should cache non-admin status too', function()
permissions.is_group_admin(api, -100123, 111)
api.reset()
local result = permissions.is_group_admin(api, -100123, 111)
assert.is_false(result)
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should return false when chat_id is nil', function()
assert.is_false(permissions.is_group_admin(api, nil, 456))
end)
it('should return false when user_id is nil', function()
assert.is_false(permissions.is_group_admin(api, -100123, nil))
end)
end)
describe('is_group_mod()', function()
it('should return true when user has moderator role', function()
db.set_next_result({ { ['1'] = 1 } })
assert.is_true(permissions.is_group_mod(db, -100123, 456))
end)
it('should return false when user is not a moderator', function()
db.set_next_result({})
assert.is_false(permissions.is_group_mod(db, -100123, 456))
end)
it('should return false for nil chat_id', function()
assert.is_false(permissions.is_group_mod(db, nil, 456))
end)
it('should return false for nil user_id', function()
assert.is_false(permissions.is_group_mod(db, -100123, nil))
end)
it('should query the correct SQL', function()
db.set_next_result({})
permissions.is_group_mod(db, -100123, 456)
- assert.is_true(db.has_query('chat_members'))
- assert.is_true(db.has_query('moderator'))
+ assert.is_true(db.has_query('sp_check_group_moderator'))
end)
end)
describe('is_trusted()', function()
it('should return true when user has trusted role', function()
db.set_next_result({ { ['1'] = 1 } })
assert.is_true(permissions.is_trusted(db, -100123, 456))
end)
it('should return false when user is not trusted', function()
db.set_next_result({})
assert.is_false(permissions.is_trusted(db, -100123, 456))
end)
end)
describe('check_bot_can()', function()
it('should return true when bot has the permission', function()
api.set_bot_admin(-100123, { can_restrict_members = true })
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_true(result)
end)
it('should return false when bot lacks the permission', function()
api.set_bot_admin(-100123, { can_restrict_members = false })
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_false(result)
end)
it('should return false when bot is not an admin', function()
-- Default: bot is a regular member
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_false(result)
end)
it('should cache the result', function()
api.set_bot_admin(-100123, { can_restrict_members = true })
permissions.check_bot_can(api, -100123, 'can_restrict_members')
api.reset()
local result = permissions.check_bot_can(api, -100123, 'can_restrict_members')
assert.is_true(result)
assert.are.equal(0, api.count_calls('get_chat_member'))
end)
it('should return false for nil chat_id', function()
assert.is_false(permissions.check_bot_can(api, nil, 'can_restrict_members'))
end)
it('should return false for nil permission', function()
assert.is_false(permissions.check_bot_can(api, -100123, nil))
end)
end)
describe('convenience permission checks', function()
it('can_restrict should check can_restrict_members', function()
api.set_bot_admin(-100123, { can_restrict_members = true })
assert.is_true(permissions.can_restrict(api, -100123))
end)
it('can_delete should check can_delete_messages', function()
api.set_bot_admin(-100123, { can_delete_messages = true })
assert.is_true(permissions.can_delete(api, -100123))
end)
it('can_pin should check can_pin_messages', function()
api.set_bot_admin(-100123, { can_pin_messages = true })
assert.is_true(permissions.can_pin(api, -100123))
end)
it('can_promote should check can_promote_members', function()
api.set_bot_admin(-100123, { can_promote_members = true })
assert.is_true(permissions.can_promote(api, -100123))
end)
it('can_invite should check can_invite_users', function()
api.set_bot_admin(-100123, { can_invite_users = true })
assert.is_true(permissions.can_invite(api, -100123))
end)
end)
describe('is_admin_or_mod()', function()
it('should return true for admin', function()
api.set_admin(-100123, 456)
assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 456))
end)
it('should return true for moderator', function()
db.set_next_result({ { ['1'] = 1 } })
assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 789))
end)
it('should return false for regular user', function()
db.set_next_result({})
assert.is_false(permissions.is_admin_or_mod(api, db, -100123, 111))
end)
it('should return true for global admin', function()
assert.is_true(permissions.is_admin_or_mod(api, db, -100123, 221714512))
end)
end)
end)
diff --git a/spec/helpers/mock_db.lua b/spec/helpers/mock_db.lua
index f82b163..4b046ad 100644
--- a/spec/helpers/mock_db.lua
+++ b/spec/helpers/mock_db.lua
@@ -1,108 +1,123 @@
--[[
mattata v2.0 - Mock PostgreSQL Database
Records queries and returns configurable results for testing.
]]
local mock_db = {}
function mock_db.new()
local db = {
queries = {},
data = {}, -- table_name -> array of rows
next_result = nil,
result_queue = {}, -- FIFO queue of results to return
}
function db.query(sql)
table.insert(db.queries, { sql = sql })
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
if #db.result_queue > 0 then
return table.remove(db.result_queue, 1)
end
return {}
end
function db.execute(sql, params)
table.insert(db.queries, { sql = sql, params = params })
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
if #db.result_queue > 0 then
return table.remove(db.result_queue, 1)
end
return {}
end
function db.insert(table_name, data_row)
table.insert(db.queries, { op = 'insert', table_name = table_name, data = data_row })
if not db.data[table_name] then db.data[table_name] = {} end
table.insert(db.data[table_name], data_row)
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
return { data_row }
end
function db.upsert(table_name, data_row, conflict_keys, update_keys)
table.insert(db.queries, { op = 'upsert', table_name = table_name, data = data_row, conflict_keys = conflict_keys, update_keys = update_keys })
if not db.data[table_name] then db.data[table_name] = {} end
table.insert(db.data[table_name], data_row)
if db.next_result then
local r = db.next_result
db.next_result = nil
return r
end
return { data_row }
end
+ -- Stored procedure call: records func_name, params, and a synthetic sql for has_query
+ function db.call(func_name, params)
+ local sql = 'SELECT * FROM ' .. func_name .. '(...)'
+ table.insert(db.queries, { op = 'call', func_name = func_name, params = params, sql = sql })
+ if db.next_result then
+ local r = db.next_result
+ db.next_result = nil
+ return r
+ end
+ if #db.result_queue > 0 then
+ return table.remove(db.result_queue, 1)
+ end
+ return {}
+ end
+
function db.set_next_result(result)
db.next_result = result
end
-- Queue multiple results (consumed in order)
function db.queue_result(result)
table.insert(db.result_queue, result)
end
function db.transaction(fn)
return fn(db.query, db.execute)
end
function db.pool_stats()
return { available = 5, max_size = 10 }
end
function db.reset()
db.queries = {}
db.data = {}
db.next_result = nil
db.result_queue = {}
end
-- Helper: check if a query matching pattern was executed
function db.has_query(pattern)
for _, q in ipairs(db.queries) do
if q.sql and q.sql:match(pattern) then
return true
end
end
return false
end
-- Helper: get the last query
function db.last_query()
return db.queries[#db.queries]
end
return db
end
return mock_db
diff --git a/spec/middleware/stats_spec.lua b/spec/middleware/stats_spec.lua
index e3043f5..a42a724 100644
--- a/spec/middleware/stats_spec.lua
+++ b/spec/middleware/stats_spec.lua
@@ -1,247 +1,247 @@
--[[
Tests for src/middleware/stats.lua
Tests message counter increment, command tracking, flush to PostgreSQL.
]]
describe('middleware.stats', function()
local stats_mw
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.middleware.stats'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
stats_mw = require('src.middleware.stats')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('name', function()
it('should be "stats"', function()
assert.are.equal('stats', stats_mw.name)
end)
end)
describe('when message has no from or chat', function()
it('should continue when no from', function()
message.from = nil
local _, should_continue = stats_mw.run(ctx, message)
assert.is_true(should_continue)
end)
it('should continue when no chat', function()
message.chat = nil
local _, should_continue = stats_mw.run(ctx, message)
assert.is_true(should_continue)
end)
it('should not increment counters when no from', function()
message.from = nil
stats_mw.run(ctx, message)
assert.are.equal(0, #env.redis.commands)
end)
end)
describe('message counter', function()
it('should increment message counter in Redis', function()
stats_mw.run(ctx, message)
-- Check that an incr command was issued
assert.is_true(env.redis.has_command('incr'))
end)
it('should use correct key format', function()
stats_mw.run(ctx, message)
local date = os.date('!%Y-%m-%d')
local expected_prefix = 'stats:msg:' .. message.chat.id .. ':' .. date
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:msg:') then
found = true
end
end
assert.is_true(found)
end)
it('should set 24h TTL on first increment', function()
stats_mw.run(ctx, message)
-- Find the stats key
for k, ttl in pairs(env.redis.ttls) do
if type(k) == 'string' and k:match('^stats:msg:') then
assert.are.equal(86400, ttl)
end
end
end)
end)
describe('command tracking', function()
it('should track command usage for / prefixed messages', function()
message.text = '/ping'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:ping:') then
found = true
end
end
assert.is_true(found)
end)
it('should track command usage for ! prefixed messages', function()
message.text = '!help'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:help:') then
found = true
end
end
assert.is_true(found)
end)
it('should track command usage for # prefixed messages', function()
message.text = '#ban user'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:ban:') then
found = true
end
end
assert.is_true(found)
end)
it('should lowercase command names', function()
message.text = '/PING'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:ping:') then
found = true
end
end
assert.is_true(found)
end)
it('should not track non-command messages', function()
message.text = 'hello world'
stats_mw.run(ctx, message)
local found = false
for k in pairs(env.redis.store) do
if type(k) == 'string' and k:match('^stats:cmd:') then
found = true
end
end
assert.is_false(found)
end)
it('should set 24h TTL on first command counter', function()
message.text = '/ping'
stats_mw.run(ctx, message)
for k, ttl in pairs(env.redis.ttls) do
if type(k) == 'string' and k:match('^stats:cmd:') then
assert.are.equal(86400, ttl)
end
end
end)
end)
describe('always continues', function()
it('should always return true', function()
local _, should_continue = stats_mw.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('flush()', function()
it('should flush message stats to PostgreSQL', function()
-- Set up some stats keys
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:msg:-100123:' .. date .. ':456', '10')
env.redis.set('stats:msg:-100123:' .. date .. ':789', '5')
stats_mw.flush(env.db, env.redis)
- -- Should have executed SQL insert/upsert
- local sql_count = 0
+ -- Should have called sp_flush_message_stats
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('message_stats') then
- sql_count = sql_count + 1
+ if q.op == 'call' and q.func_name == 'sp_flush_message_stats' then
+ call_count = call_count + 1
end
end
- assert.is_true(sql_count > 0)
+ assert.is_true(call_count > 0)
end)
it('should flush command stats to PostgreSQL', function()
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:cmd:ping:-100123:' .. date, '25')
stats_mw.flush(env.db, env.redis)
- local sql_count = 0
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('command_stats') then
- sql_count = sql_count + 1
+ if q.op == 'call' and q.func_name == 'sp_flush_command_stats' then
+ call_count = call_count + 1
end
end
- assert.is_true(sql_count > 0)
+ assert.is_true(call_count > 0)
end)
it('should delete Redis keys after flushing', function()
local date = os.date('!%Y-%m-%d')
local key = 'stats:msg:-100123:' .. date .. ':456'
env.redis.set(key, '10')
stats_mw.flush(env.db, env.redis)
assert.is_nil(env.redis.store[key])
end)
it('should skip keys with zero count', function()
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:msg:-100123:' .. date .. ':456', '0')
stats_mw.flush(env.db, env.redis)
- -- Should not have executed any message_stats SQL
- local sql_count = 0
+ -- Should not have called sp_flush_message_stats
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('message_stats') then
- sql_count = sql_count + 1
+ if q.op == 'call' and q.func_name == 'sp_flush_message_stats' then
+ call_count = call_count + 1
end
end
- assert.are.equal(0, sql_count)
+ assert.are.equal(0, call_count)
end)
it('should handle empty stats gracefully', function()
assert.has_no.errors(function()
stats_mw.flush(env.db, env.redis)
end)
end)
- it('should use ON CONFLICT upsert SQL', function()
+ it('should call stored procedures for flush', function()
local date = os.date('!%Y-%m-%d')
env.redis.set('stats:msg:-100123:' .. date .. ':456', '10')
stats_mw.flush(env.db, env.redis)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('ON CONFLICT') then
+ if q.op == 'call' and q.func_name:match('^sp_flush_') then
found = true
end
end
assert.is_true(found)
end)
end)
end)
diff --git a/spec/middleware/user_tracker_spec.lua b/spec/middleware/user_tracker_spec.lua
index 3d374f6..3a99d05 100644
--- a/spec/middleware/user_tracker_spec.lua
+++ b/spec/middleware/user_tracker_spec.lua
@@ -1,221 +1,217 @@
--[[
Tests for src/middleware/user_tracker.lua
Tests debouncing, upsert on first message, username mapping.
]]
describe('middleware.user_tracker', function()
local user_tracker
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
package.loaded['src.middleware.user_tracker'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
user_tracker = require('src.middleware.user_tracker')
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('name', function()
it('should be "user_tracker"', function()
assert.are.equal('user_tracker', user_tracker.name)
end)
end)
describe('when message has no from', function()
it('should continue processing', function()
message.from = nil
local new_ctx, should_continue = user_tracker.run(ctx, message)
assert.is_true(should_continue)
end)
end)
describe('debouncing', function()
it('should skip DB upsert when dedup key exists', function()
-- Set dedup key to simulate recent activity
local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
env.redis.set(dedup_key, '1')
user_tracker.run(ctx, message)
- -- Should NOT have done any upserts
- local upsert_count = 0
+ -- Should NOT have done any calls
+ local call_count = 0
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' then upsert_count = upsert_count + 1 end
+ if q.op == 'call' then call_count = call_count + 1 end
end
- assert.are.equal(0, upsert_count)
+ assert.are.equal(0, call_count)
end)
it('should still update username mapping when debounced', function()
local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
env.redis.set(dedup_key, '1')
user_tracker.run(ctx, message)
-- Should have set username mapping
assert.is_not_nil(env.redis.store['username:testuser'])
end)
it('should upsert on first message (no dedup key)', function()
user_tracker.run(ctx, message)
- -- Should have upserted user
+ -- Should have called sp_upsert_user
local user_upserted = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
user_upserted = true
end
end
assert.is_true(user_upserted)
end)
it('should set dedup key with 60s TTL', function()
user_tracker.run(ctx, message)
local dedup_key = string.format('seen:%s:%s', message.from.id, message.chat.id)
assert.is_not_nil(env.redis.store[dedup_key])
assert.are.equal(60, env.redis.ttls[dedup_key])
end)
end)
describe('user upsert', function()
it('should upsert user data to PostgreSQL', function()
user_tracker.run(ctx, message)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
found = true
- assert.are.equal(message.from.id, q.data.user_id)
- assert.are.equal('testuser', q.data.username)
- assert.are.equal('Test', q.data.first_name)
+ -- params: user_id, username, first_name, last_name, language_code, is_bot, last_seen
+ assert.are.equal(message.from.id, q.params[1])
+ assert.are.equal('testuser', q.params[2])
+ assert.are.equal('Test', q.params[3])
end
end
assert.is_true(found)
end)
it('should handle users without username', function()
message.from.username = nil
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
- assert.is_nil(q.data.username)
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
+ assert.is_nil(q.params[2])
end
end
end)
it('should lowercase username', function()
message.from.username = 'TestUser'
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
- assert.are.equal('testuser', q.data.username)
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
+ assert.are.equal('testuser', q.params[2])
end
end
end)
- it('should use correct conflict and update keys', function()
+ it('should pass all required fields', function()
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'users' then
- assert.are.same({ 'user_id' }, q.conflict_keys)
- -- Update keys should include username, first_name, etc.
- local has_username = false
- for _, k in ipairs(q.update_keys) do
- if k == 'username' then has_username = true end
- end
- assert.is_true(has_username)
+ if q.op == 'call' and q.func_name == 'sp_upsert_user' then
+ -- 7 params: user_id, username, first_name, last_name, language_code, is_bot, last_seen
+ assert.are.equal(7, q.params.n or #q.params)
end
end
end)
end)
describe('chat upsert', function()
it('should upsert chat data for group messages', function()
user_tracker.run(ctx, message)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'chats' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_chat' then
found = true
- assert.are.equal(message.chat.id, q.data.chat_id)
- assert.are.equal('Test Group', q.data.title)
+ assert.are.equal(message.chat.id, q.params[1])
+ assert.are.equal('Test Group', q.params[2])
end
end
assert.is_true(found)
end)
it('should not upsert chat for private messages', function()
message.chat.type = 'private'
user_tracker.run(ctx, message)
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'chats' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_chat' then
assert.fail('should not upsert chat for private messages')
end
end
end)
it('should track user-chat membership', function()
user_tracker.run(ctx, message)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'upsert' and q.table_name == 'chat_members' then
+ if q.op == 'call' and q.func_name == 'sp_upsert_chat_member' then
found = true
- assert.are.equal(message.chat.id, q.data.chat_id)
- assert.are.equal(message.from.id, q.data.user_id)
+ assert.are.equal(message.chat.id, q.params[1])
+ assert.are.equal(message.from.id, q.params[2])
end
end
assert.is_true(found)
end)
end)
describe('username mapping', function()
it('should set username -> user_id mapping in Redis', function()
user_tracker.run(ctx, message)
local stored_id = env.redis.store['username:testuser']
assert.is_not_nil(stored_id)
assert.are.equal(tostring(message.from.id), stored_id)
end)
it('should not set mapping when username is nil', function()
message.from.username = nil
user_tracker.run(ctx, message)
-- No username:* key should exist
local found = false
for k in pairs(env.redis.store) do
if k:match('^username:') then found = true end
end
assert.is_false(found)
end)
it('should lowercase username in mapping', function()
message.from.username = 'TestUser'
user_tracker.run(ctx, message)
assert.is_not_nil(env.redis.store['username:testuser'])
assert.is_nil(env.redis.store['username:TestUser'])
end)
end)
describe('always continues', function()
it('should always return true for should_continue', function()
local _, should_continue = user_tracker.run(ctx, message)
assert.is_true(should_continue)
end)
end)
end)
diff --git a/spec/plugins/admin/ban_spec.lua b/spec/plugins/admin/ban_spec.lua
index 5e15f5d..73700fd 100644
--- a/spec/plugins/admin/ban_spec.lua
+++ b/spec/plugins/admin/ban_spec.lua
@@ -1,296 +1,296 @@
--[[
Tests for src/plugins/admin/ban.lua
Tests target resolution (reply, args, username), admin check, bot permission check,
ban execution, and logging.
]]
describe('plugins.admin.ban', function()
local ban_plugin
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
-- Mock dependencies
package.loaded['src.plugins.admin.ban'] = nil
package.loaded['src.core.logger'] = {
debug = function() end,
info = function() end,
warn = function() end,
error = function() end,
}
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
is_enabled = function() return false end,
bot_admins = function() return {} end,
load = function() end,
VERSION = '2.0',
}
package.loaded['src.core.session'] = {
get_admin_status = function() return nil end,
set_admin_status = function() end,
get_cached_setting = function(chat_id, key, fetch_fn, ttl)
return fetch_fn()
end,
}
package.loaded['src.core.permissions'] = {
is_global_admin = function() return false end,
is_group_admin = function(api, chat_id, user_id)
-- Target user is not admin by default
return false
end,
can_restrict = function(api, chat_id)
-- Bot can restrict by default in tests
return true
end,
}
-- Mock telegram-bot-lua.tools
package.loaded['telegram-bot-lua.tools'] = {
escape_html = function(text)
if not text then return '' end
return tostring(text):gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
end,
}
ban_plugin = require('src.plugins.admin.ban')
env = test_helper.setup()
message = test_helper.make_message({
text = '/ban',
command = 'ban',
})
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('plugin metadata', function()
it('should have name "ban"', function()
assert.are.equal('ban', ban_plugin.name)
end)
it('should be in admin category', function()
assert.are.equal('admin', ban_plugin.category)
end)
it('should be group_only', function()
assert.is_true(ban_plugin.group_only)
end)
it('should be admin_only', function()
assert.is_true(ban_plugin.admin_only)
end)
it('should have ban and b commands', function()
assert.are.same({ 'ban', 'b' }, ban_plugin.commands)
end)
it('should have a help string', function()
assert.is_truthy(ban_plugin.help)
assert.is_truthy(ban_plugin.help:match('/ban'))
end)
end)
describe('bot permission check', function()
it('should error when bot lacks restrict permission', function()
package.loaded['src.core.permissions'].can_restrict = function() return false end
-- Reload plugin to pick up new mock
package.loaded['src.plugins.admin.ban'] = nil
ban_plugin = require('src.plugins.admin.ban')
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Ban Users')
end)
end)
describe('target resolution', function()
it('should prompt when no target specified', function()
message.args = nil
message.reply = nil
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify')
end)
it('should resolve target from reply', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = nil
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
local call = env.api.get_call('ban_chat_member')
assert.are.equal(222222, call.args[2])
end)
it('should resolve target from user ID in args', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
end)
it('should resolve target from username in args', function()
env.redis.set('username:targetuser', '333333')
message.args = '@targetuser'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_called(env.api, 'ban_chat_member')
local call = env.api.get_call('ban_chat_member')
assert.are.equal(333333, call.args[2])
end)
it('should extract reason from args after user ID', function()
message.args = '222222 spamming links'
ban_plugin.on_message(env.api, message, ctx)
-- Check that reason was logged to DB
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal('spamming links', q.data.reason)
+ assert.are.equal('spamming links', q.params[4])
end
end
assert.is_true(found)
end)
it('should extract reason from args when replying', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = 'being disruptive'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal('being disruptive', q.data.reason)
+ assert.are.equal('being disruptive', q.params[4])
end
end
assert.is_true(found)
end)
it('should strip "for" prefix from reason', function()
message.args = '222222 for spamming'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal('spamming', q.data.reason)
+ assert.are.equal('spamming', q.params[4])
end
end
assert.is_true(found)
end)
it('should not ban the bot itself', function()
message.args = tostring(env.api.info.id)
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_not_called(env.api, 'ban_chat_member')
end)
end)
describe('admin target check', function()
it('should not ban an admin', function()
package.loaded['src.core.permissions'].is_group_admin = function(api, chat_id, user_id)
if user_id == 222222 then return true end
return false
end
package.loaded['src.plugins.admin.ban'] = nil
ban_plugin = require('src.plugins.admin.ban')
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
test_helper.assert_api_not_called(env.api, 'ban_chat_member')
test_helper.assert_sent_message_matches(env.api, "can't ban")
end)
end)
describe('ban execution', function()
it('should call ban_chat_member with correct chat and user', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local call = env.api.get_call('ban_chat_member')
assert.is_not_nil(call)
assert.are.equal(message.chat.id, call.args[1])
assert.are.equal(222222, call.args[2])
end)
it('should send success message with HTML', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local calls = env.api.get_calls('send_message')
-- Find the success message (not the prompt)
local found = false
for _, call in ipairs(calls) do
if call.args[2]:match('has banned') then
found = true
assert.are.equal('html', call.args[3])
end
end
assert.is_true(found)
end)
end)
describe('logging', function()
it('should log ban to bans table', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'bans' then
+ if q.op == 'call' and q.func_name == 'sp_insert_ban' then
found = true
- assert.are.equal(message.chat.id, q.data.chat_id)
- assert.are.equal(222222, q.data.user_id)
- assert.are.equal(message.from.id, q.data.banned_by)
+ assert.are.equal(message.chat.id, q.params[1])
+ assert.are.equal(222222, q.params[2])
+ assert.are.equal(message.from.id, q.params[3])
end
end
assert.is_true(found)
end)
it('should log ban to admin_actions table', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, q in ipairs(env.db.queries) do
- if q.op == 'insert' and q.table_name == 'admin_actions' then
+ if q.op == 'call' and q.func_name == 'sp_log_admin_action' then
found = true
- assert.are.equal('ban', q.data.action)
- assert.are.equal(message.from.id, q.data.admin_id)
- assert.are.equal(222222, q.data.target_id)
+ assert.are.equal('ban', q.params[4])
+ assert.are.equal(message.from.id, q.params[2])
+ assert.are.equal(222222, q.params[3])
end
end
assert.is_true(found)
end)
end)
describe('message cleanup', function()
it('should delete the command message', function()
message.args = '222222'
ban_plugin.on_message(env.api, message, ctx)
-- Should have called delete_message for the command
local found = false
for _, call in ipairs(env.api.calls) do
if call.method == 'delete_message' and call.args[2] == message.message_id then
found = true
end
end
assert.is_true(found)
end)
it('should delete the replied-to message', function()
message.reply = {
from = { id = 222222, first_name = 'Target' },
message_id = 50,
}
message.args = nil
ban_plugin.on_message(env.api, message, ctx)
local found = false
for _, call in ipairs(env.api.calls) do
if call.method == 'delete_message' and call.args[2] == 50 then
found = true
end
end
assert.is_true(found)
end)
end)
end)
diff --git a/spec/plugins/admin/federation_spec.lua b/spec/plugins/admin/federation_spec.lua
index 9553857..7caad06 100644
--- a/spec/plugins/admin/federation_spec.lua
+++ b/spec/plugins/admin/federation_spec.lua
@@ -1,427 +1,427 @@
--[[
Tests for federation plugins: newfed, joinfed, fban, unfban, fbaninfo.
]]
describe('plugins.admin.federation', function()
local test_helper = require('spec.helpers.test_helper')
local env, ctx, message
before_each(function()
-- Mock shared dependencies
package.loaded['telegram-bot-lua.tools'] = {
escape_html = function(text)
if not text then return '' end
return tostring(text):gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;')
end,
}
package.loaded['src.core.logger'] = {
debug = function() end, info = function() end,
warn = function() end, error = function() end,
}
package.loaded['src.core.config'] = {
get = function(key, default) return default end,
is_enabled = function() return false end,
bot_admins = function() return {} end,
load = function() end, VERSION = '2.0',
}
package.loaded['src.core.session'] = {
get_admin_status = function() return nil end,
set_admin_status = function() end,
get_cached_setting = function(chat_id, key, fetch_fn, ttl)
return fetch_fn()
end,
}
package.loaded['src.core.permissions'] = {
is_global_admin = function() return false end,
is_group_admin = function() return false end,
can_restrict = function() return true end,
}
env = test_helper.setup()
message = test_helper.make_message()
ctx = test_helper.make_ctx(env)
end)
after_each(function()
test_helper.teardown(env)
end)
describe('newfed', function()
local newfed
before_each(function()
package.loaded['src.plugins.admin.federation.newfed'] = nil
newfed = require('src.plugins.admin.federation.newfed')
end)
it('should have correct metadata', function()
assert.are.equal('newfed', newfed.name)
assert.are.same({ 'newfed' }, newfed.commands)
end)
it('should require a name argument', function()
message.args = nil
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a name')
end)
it('should require a name argument when empty string', function()
message.args = ''
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a name')
end)
it('should reject names longer than 128 characters', function()
message.args = string.rep('a', 129)
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, '128 characters')
end)
it('should allow names of exactly 128 characters', function()
message.args = string.rep('a', 128)
env.db.queue_result({ { count = 0 } }) -- existing count
env.db.queue_result({ { id = 'test-uuid' } }) -- insert result
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'created successfully')
end)
it('should reject when user already owns 5 federations', function()
message.args = 'Test Fed'
env.db.set_next_result({ { count = 5 } })
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'maximum')
end)
it('should create federation and return ID', function()
message.args = 'My Federation'
env.db.queue_result({ { count = 0 } })
env.db.queue_result({ { id = 'uuid-1234' } })
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'created successfully')
test_helper.assert_sent_message_matches(env.api, 'uuid%-1234')
end)
it('should handle DB failure gracefully', function()
message.args = 'Test Fed'
env.db.queue_result({ { count = 0 } })
env.db.queue_result({}) -- empty result = failure
newfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Failed')
end)
end)
describe('joinfed', function()
local joinfed
before_each(function()
package.loaded['src.plugins.admin.federation.joinfed'] = nil
joinfed = require('src.plugins.admin.federation.joinfed')
end)
it('should have correct metadata', function()
assert.are.equal('joinfed', joinfed.name)
assert.is_true(joinfed.group_only)
assert.is_true(joinfed.admin_only)
end)
it('should require federation ID argument', function()
message.args = nil
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify the federation ID')
end)
it('should reject when chat is already in a federation', function()
message.args = 'new-fed-id'
env.db.set_next_result({ { id = 'old-fed-id', name = 'Old Fed' } })
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'already part')
end)
it('should reject when federation does not exist', function()
message.args = 'nonexistent-id'
env.db.queue_result({}) -- not in federation
env.db.queue_result({}) -- federation not found
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not found')
end)
it('should successfully join a federation', function()
message.args = 'fed-uuid'
env.db.queue_result({}) -- not in federation
env.db.queue_result({ { id = 'fed-uuid', name = 'Test Fed' } }) -- fed exists
env.db.queue_result({ {} }) -- insert result
joinfed.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'has joined')
end)
end)
describe('fban', function()
local fban
before_each(function()
package.loaded['src.plugins.admin.federation.fban'] = nil
fban = require('src.plugins.admin.federation.fban')
end)
it('should have correct metadata', function()
assert.are.equal('fban', fban.name)
assert.are.same({ 'fban' }, fban.commands)
end)
it('should require chat to be in a federation', function()
env.db.set_next_result({}) -- no federation
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not part of any federation')
end)
it('should require federation admin/owner permission', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 999 } })
env.db.queue_result({}) -- not a fed admin
message.from.id = 111111
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'federation owner or a federation admin')
end)
it('should require a target user', function()
env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
message.args = nil
message.reply = nil
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a user')
end)
it('should not ban the federation owner', function()
message.from.id = 111111
env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
message.reply = { from = { id = 111111, first_name = 'Owner' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'cannot federation%-ban the federation owner')
end)
it('should not ban allowlisted users', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({ { ['1'] = 1 } }) -- is allowlisted
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'allowlist')
end)
it('should ban user across federation chats', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } }) -- get fed
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert ban
env.db.queue_result({ { chat_id = -100111 }, { chat_id = -100222 } }) -- fed chats
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
assert.are.equal(2, env.api.count_calls('ban_chat_member'))
test_helper.assert_sent_message_matches(env.api, 'Federation Ban')
end)
it('should invalidate Redis cache after fban', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert
env.db.queue_result({}) -- chats
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
fban.on_message(env.api, message, ctx)
test_helper.assert_redis_command(env.redis, 'del')
end)
it('should include reason in ban record', function()
message.from.id = 111111
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert
env.db.queue_result({}) -- chats
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
message.args = 'spamming links'
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'spamming links')
end)
it('should resolve user from username', function()
message.from.id = 111111
env.redis.set('username:targetuser', '333333')
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 111111 } })
env.db.queue_result({}) -- not allowlisted
env.db.queue_result({}) -- not already banned
env.db.queue_result({}) -- insert
env.db.queue_result({}) -- chats
message.args = '@targetuser reason'
message.reply = nil
fban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Federation Ban')
end)
end)
describe('unfban', function()
local unfban
before_each(function()
package.loaded['src.plugins.admin.federation.unfban'] = nil
unfban = require('src.plugins.admin.federation.unfban')
end)
it('should have correct metadata', function()
assert.are.equal('unfban', unfban.name)
assert.is_true(unfban.group_only)
end)
it('should require federation membership', function()
env.db.set_next_result({})
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not part of any federation')
end)
it('should require federation admin permission', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = 999 } })
env.db.queue_result({}) -- not a fed admin
message.from.id = 111111
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'federation owner or a federation admin')
end)
it('should require a target user', function()
env.db.set_next_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
message.args = nil
message.reply = nil
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'specify a user')
end)
it('should report when user is not banned', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
env.db.queue_result({}) -- not banned
message.args = '222222'
unfban.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should unban user across federation chats', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
env.db.queue_result({ { ['1'] = 1 } }) -- is banned
env.db.queue_result({}) -- delete ban
env.db.queue_result({ { chat_id = -100111 }, { chat_id = -100222 } }) -- fed chats
message.args = '222222'
unfban.on_message(env.api, message, ctx)
assert.are.equal(2, env.api.count_calls('unban_chat_member'))
test_helper.assert_sent_message_matches(env.api, 'Federation Unban')
end)
it('should invalidate Redis cache after unfban', function()
env.db.queue_result({ { id = 'fed-1', name = 'Fed', owner_id = message.from.id } })
env.db.queue_result({ { ['1'] = 1 } })
env.db.queue_result({})
env.db.queue_result({})
message.args = '222222'
unfban.on_message(env.api, message, ctx)
test_helper.assert_redis_command(env.redis, 'del')
end)
end)
describe('fbaninfo', function()
local fbaninfo
before_each(function()
package.loaded['src.plugins.admin.federation.fbaninfo'] = nil
fbaninfo = require('src.plugins.admin.federation.fbaninfo')
end)
it('should have correct metadata', function()
assert.are.equal('fbaninfo', fbaninfo.name)
assert.are.same({ 'fbaninfo' }, fbaninfo.commands)
end)
it('should default to sender when no user specified', function()
message.args = nil
message.reply = nil
ctx.is_group = true
env.db.set_next_result({}) -- no bans found
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should show ban info for banned user (group context)', function()
ctx.is_group = true
env.db.set_next_result({
{
reason = 'Spamming',
banned_by = 111111,
banned_at = '2024-01-01',
name = 'Test Fed',
id = 'fed-uuid',
}
})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Federation Ban Info')
test_helper.assert_sent_message_matches(env.api, 'Spamming')
end)
it('should show no-ban message for clean user', function()
ctx.is_group = true
env.db.set_next_result({})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should resolve user from reply', function()
ctx.is_group = true
message.reply = { from = { id = 222222, first_name = 'Target' }, message_id = 1 }
message.args = nil
env.db.set_next_result({})
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'not banned')
end)
it('should query all federations in private chat', function()
ctx.is_group = false
ctx.is_private = true
message.chat.type = 'private'
env.db.set_next_result({})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
- -- Verify it queried without chat_id constraint
+ -- Verify it used the "all" stored procedure (not group-scoped)
local found_private_query = false
for _, q in ipairs(env.db.queries) do
- if q.sql and q.sql:match('federation_bans') and not q.sql:match('fc%.chat_id') then
+ if q.op == 'call' and q.func_name == 'sp_get_fban_info_all' then
found_private_query = true
end
end
assert.is_true(found_private_query)
end)
it('should show multiple bans', function()
ctx.is_group = false
ctx.is_private = true
message.chat.type = 'private'
env.db.set_next_result({
{ reason = 'Reason 1', name = 'Fed A', id = 'fed-1', banned_by = 111, banned_at = '2024-01-01' },
{ reason = 'Reason 2', name = 'Fed B', id = 'fed-2', banned_by = 222, banned_at = '2024-02-01' },
})
message.args = '222222'
fbaninfo.on_message(env.api, message, ctx)
test_helper.assert_sent_message_matches(env.api, 'Fed A')
test_helper.assert_sent_message_matches(env.api, 'Fed B')
end)
end)
end)
diff --git a/src/core/database.lua b/src/core/database.lua
index fe99e4a..e33ac3d 100644
--- a/src/core/database.lua
+++ b/src/core/database.lua
@@ -1,327 +1,369 @@
--[[
mattata v2.1 - PostgreSQL Database Module
Uses pgmoon for async-compatible PostgreSQL connections.
Implements connection pooling with copas semaphore guards,
automatic reconnection, and transaction helpers.
]]
local database = {}
local pgmoon = require('pgmoon')
local config = require('src.core.config')
local logger = require('src.core.logger')
local copas_sem = require('copas.semaphore')
local pool = {}
local pool_size = 10
local pool_timeout = 30000
local pool_semaphore = nil
local db_config = nil
-- Initialise pool configuration
local function get_config()
if not db_config then
db_config = config.database()
end
return db_config
end
-- Create a new pgmoon connection
local function create_connection()
local cfg = get_config()
local pg = pgmoon.new({
host = cfg.host,
port = cfg.port,
database = cfg.database,
user = cfg.user,
password = cfg.password
})
local ok, err = pg:connect()
if not ok then
return nil, err
end
pg:settimeout(pool_timeout)
return pg
end
function database.connect()
local cfg = get_config()
pool_size = config.get_number('DATABASE_POOL_SIZE', 10)
pool_timeout = config.get_number('DATABASE_TIMEOUT', 30000)
-- Create initial connection to validate credentials
local pg, err = create_connection()
if not pg then
logger.error('Failed to connect to PostgreSQL: %s', tostring(err))
return false, err
end
table.insert(pool, pg)
-- Create semaphore to guard concurrent pool access
-- max = pool_size, start = pool_size (all permits available), timeout = 30s
pool_semaphore = copas_sem.new(pool_size, pool_size, 30)
logger.info('Connected to PostgreSQL at %s:%d/%s (pool size: %d)', cfg.host, cfg.port, cfg.database, pool_size)
return true
end
-- Acquire a connection from the pool
function database.acquire()
-- Take a semaphore permit (blocks coroutine if pool exhausted, 30s timeout)
if pool_semaphore then
local ok, err = pool_semaphore:take(1, 30)
if not ok then
logger.error('Failed to acquire pool permit: %s', tostring(err))
return nil, 'Pool exhausted (semaphore timeout)'
end
end
if #pool > 0 then
return table.remove(pool)
end
-- Pool exhausted — create a new connection
local pg, err = create_connection()
if not pg then
logger.error('Failed to create new connection: %s', tostring(err))
-- Return the permit since we failed to use it
if pool_semaphore then pool_semaphore:give(1) end
return nil, err
end
return pg
end
-- Release a connection back to the pool
function database.release(pg)
if not pg then return end
if #pool < pool_size then
table.insert(pool, pg)
else
pcall(function() pg:disconnect() end)
end
-- Return the semaphore permit
if pool_semaphore then pool_semaphore:give(1) end
end
-- Execute a raw SQL query with automatic connection management
function database.query(sql, ...)
local pg, err = database.acquire()
if not pg then
logger.error('Database not connected')
return nil, 'Database not connected'
end
local result, query_err, _, _ = pg:query(sql)
if not result then
-- Check for connection loss and attempt reconnect
if query_err and (query_err:match('closed') or query_err:match('broken') or query_err:match('timeout')) then
logger.warn('Connection lost, attempting reconnect...')
pcall(function() pg:disconnect() end)
-- Release the dead connection's permit before reconnect
if pool_semaphore then pool_semaphore:give(1) end
pg, err = create_connection()
if pg then
-- Re-acquire a permit for the new connection
if pool_semaphore then
local ok, sem_err = pool_semaphore:take(1, 30)
if not ok then
pcall(function() pg:disconnect() end)
logger.error('Reconnect semaphore acquire failed: %s', tostring(sem_err))
return nil, 'Pool exhausted during reconnect'
end
end
result, query_err = pg:query(sql)
if result then
database.release(pg)
return result
end
database.release(pg)
end
logger.error('Reconnect failed for query: %s', tostring(query_err or err))
return nil, query_err or err
end
logger.error('Query failed: %s\nSQL: %s', tostring(query_err), sql)
database.release(pg)
return nil, query_err
end
database.release(pg)
return result
end
-- Execute a parameterized query (manually escape values)
function database.execute(sql, params)
local pg, _ = database.acquire()
if not pg then
return nil, 'Database not connected'
end
if params then
local escaped = {}
for i, v in ipairs(params) do
if v == nil then
escaped[i] = 'NULL'
elseif type(v) == 'number' then
escaped[i] = tostring(v)
elseif type(v) == 'boolean' then
escaped[i] = v and 'TRUE' or 'FALSE'
else
escaped[i] = pg:escape_literal(tostring(v))
end
end
-- Replace $1, $2, etc. with escaped values
sql = sql:gsub('%$(%d+)', function(n)
return escaped[tonumber(n)] or '$' .. n
end)
end
local result, query_err = pg:query(sql)
if not result then
-- Attempt reconnect on connection failure
if query_err and (query_err:match('closed') or query_err:match('broken') or query_err:match('timeout')) then
logger.warn('Connection lost during execute, reconnecting...')
pcall(function() pg:disconnect() end)
-- Release the dead connection's permit before reconnect
if pool_semaphore then pool_semaphore:give(1) end
local new_pg
new_pg, _ = create_connection()
if new_pg then
-- Re-acquire a permit for the new connection
if pool_semaphore then
local ok, sem_err = pool_semaphore:take(1, 30)
if not ok then
pcall(function() new_pg:disconnect() end)
logger.error('Reconnect semaphore acquire failed: %s', tostring(sem_err))
return nil, 'Pool exhausted during reconnect'
end
end
result, query_err = new_pg:query(sql)
if result then
database.release(new_pg)
return result
end
database.release(new_pg)
end
else
database.release(pg)
end
logger.error('Query failed: %s\nSQL: %s', tostring(query_err), sql)
return nil, query_err
end
database.release(pg)
return result
end
-- Run a function inside a transaction (BEGIN / COMMIT / ROLLBACK)
function database.transaction(fn)
local pg, _ = database.acquire()
if not pg then
return nil, 'Database not connected'
end
local ok, begin_err = pg:query('BEGIN')
if not ok then
database.release(pg)
return nil, begin_err
end
-- Build a scoped query function for this connection
local function scoped_query(sql)
return pg:query(sql)
end
local function scoped_execute(sql, params)
if params then
local escaped = {}
for i, v in ipairs(params) do
if v == nil then
escaped[i] = 'NULL'
elseif type(v) == 'number' then
escaped[i] = tostring(v)
elseif type(v) == 'boolean' then
escaped[i] = v and 'TRUE' or 'FALSE'
else
escaped[i] = pg:escape_literal(tostring(v))
end
end
sql = sql:gsub('%$(%d+)', function(n)
return escaped[tonumber(n)] or '$' .. n
end)
end
return pg:query(sql)
end
local success, result = pcall(fn, scoped_query, scoped_execute)
if success then
pg:query('COMMIT')
database.release(pg)
return result
else
pg:query('ROLLBACK')
database.release(pg)
logger.error('Transaction failed: %s', tostring(result))
return nil, result
end
end
-- Convenience: insert and return the row
function database.insert(table_name, data)
local columns = {}
local values = {}
local params = {}
local i = 1
for k, v in pairs(data) do
table.insert(columns, k)
table.insert(values, '$' .. i)
table.insert(params, v)
i = i + 1
end
local sql = string.format(
'INSERT INTO %s (%s) VALUES (%s) RETURNING *',
table_name,
table.concat(columns, ', '),
table.concat(values, ', ')
)
return database.execute(sql, params)
end
-- Convenience: upsert (INSERT ON CONFLICT UPDATE)
function database.upsert(table_name, data, conflict_keys, update_keys)
local columns = {}
local values = {}
local params = {}
local i = 1
for k, v in pairs(data) do
table.insert(columns, k)
table.insert(values, '$' .. i)
table.insert(params, v)
i = i + 1
end
local updates = {}
for _, k in ipairs(update_keys) do
table.insert(updates, k .. ' = EXCLUDED.' .. k)
end
local sql = string.format(
'INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s RETURNING *',
table_name,
table.concat(columns, ', '),
table.concat(values, ', '),
table.concat(conflict_keys, ', '),
table.concat(updates, ', ')
)
return database.execute(sql, params)
end
--- Get the raw pgmoon connection for advanced usage
+-- call a stored procedure: SELECT * FROM func_name(arg1, arg2, ...)
+-- func_name is validated to contain only safe characters (alphanumeric + underscore)
+-- nil values are inlined as NULL; non-nil values are escaped inline
+function database.call(func_name, params, nparams)
+ if not func_name:match('^[%w_]+$') then
+ logger.error('Invalid stored procedure name: %s', func_name)
+ return nil, 'Invalid stored procedure name'
+ end
+ params = params or {}
+ nparams = nparams or params.n or #params
+ local pg, acquire_err = database.acquire()
+ if not pg then
+ return nil, acquire_err or 'Database not connected'
+ end
+ local args = {}
+ for i = 1, nparams do
+ local v = params[i]
+ if v == nil then
+ args[i] = 'NULL'
+ elseif type(v) == 'number' then
+ args[i] = tostring(v)
+ elseif type(v) == 'boolean' then
+ args[i] = v and 'TRUE' or 'FALSE'
+ else
+ args[i] = pg:escape_literal(tostring(v))
+ end
+ end
+ local sql = string.format(
+ 'SELECT * FROM %s(%s)',
+ func_name,
+ table.concat(args, ', ')
+ )
+ local result, query_err = pg:query(sql)
+ if not result then
+ logger.error('Query failed: %s\nSQL: %s', tostring(query_err), sql)
+ database.release(pg)
+ return nil, query_err
+ end
+ database.release(pg)
+ return result
+end
+
+-- get the raw pgmoon connection for advanced usage
function database.connection()
return database.acquire()
end
-- Get current pool stats
function database.pool_stats()
return {
available = #pool,
max_size = pool_size
}
end
function database.disconnect()
for _, pg in ipairs(pool) do
pcall(function() pg:disconnect() end)
end
pool = {}
pool_semaphore = nil
logger.info('Disconnected from PostgreSQL (pool drained)')
end
return database
diff --git a/src/core/permissions.lua b/src/core/permissions.lua
index c57ed90..19aa3b5 100644
--- a/src/core/permissions.lua
+++ b/src/core/permissions.lua
@@ -1,129 +1,123 @@
--[[
mattata v2.0 - Permissions Module
Centralised permission checks for admin/mod/trusted roles.
Includes bot permission checks with Redis caching.
]]
local permissions = {}
local config = require('src.core.config')
local session = require('src.core.session')
-- Check if a user is a global bot admin
function permissions.is_global_admin(user_id)
user_id = tonumber(user_id)
if not user_id then
return false
end
for _, admin_id in ipairs(config.bot_admins()) do
if tonumber(admin_id) == user_id then
return true
end
end
return false
end
-- Check if a user is a group admin (Telegram admin/creator) or bot global admin
function permissions.is_group_admin(api, chat_id, user_id)
if not chat_id or not user_id then
return false
end
if permissions.is_global_admin(user_id) then
return true
end
-- Check cache first
local cached = session.get_admin_status(chat_id, user_id)
if cached ~= nil then
return cached
end
-- Query Telegram API
local member, err = api.get_chat_member(chat_id, user_id)
if not member or not member.result then
return false, err
end
local status = member.result.status
local is_admin = (status == 'creator' or status == 'administrator')
session.set_admin_status(chat_id, user_id, is_admin)
return is_admin, status
end
-- Check if a user is a moderator (custom role, stored in PostgreSQL)
function permissions.is_group_mod(db, chat_id, user_id)
if not chat_id or not user_id then
return false
end
- local result = db.execute(
- "SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2 AND role = 'moderator'",
- { chat_id, user_id }
- )
+ local result = db.call('sp_check_group_moderator', { chat_id, user_id })
return result and #result > 0
end
--- Check if a user is trusted in a group
+-- check if a user is trusted in a group
function permissions.is_trusted(db, chat_id, user_id)
if not chat_id or not user_id then
return false
end
- local result = db.execute(
- "SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2 AND role = 'trusted'",
- { chat_id, user_id }
- )
+ local result = db.call('sp_check_trusted_user', { chat_id, user_id })
return result and #result > 0
end
-- Check if the bot has a specific permission in a chat (cached for 5 min)
-- permission: 'can_restrict_members', 'can_delete_messages', 'can_promote_members',
-- 'can_pin_messages', 'can_invite_users'
function permissions.check_bot_can(api, chat_id, permission)
if not chat_id or not permission then
return false
end
-- Check cache first
local cache_key = string.format('bot_perm:%s', permission)
local cached = session.get_cached_setting(chat_id, cache_key, function()
local member, _ = api.get_chat_member(chat_id, api.info.id)
if not member or not member.result then
return nil
end
if member.result.status ~= 'administrator' then
return 'false'
end
return member.result[permission] and 'true' or 'false'
end, 300)
return cached == 'true'
end
-- Check if the bot can restrict members in a chat
function permissions.can_restrict(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_restrict_members')
end
-- Check if the bot can delete messages
function permissions.can_delete(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_delete_messages')
end
-- Check if the bot can promote members
function permissions.can_promote(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_promote_members')
end
-- Check if the bot can pin messages
function permissions.can_pin(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_pin_messages')
end
-- Check if the bot can invite users
function permissions.can_invite(api, chat_id)
return permissions.check_bot_can(api, chat_id, 'can_invite_users')
end
-- Check if a user has admin OR mod rights
function permissions.is_admin_or_mod(api, db, chat_id, user_id)
if permissions.is_group_admin(api, chat_id, user_id) then
return true
end
return permissions.is_group_mod(db, chat_id, user_id)
end
return permissions
diff --git a/src/db/init.lua b/src/db/init.lua
index 7c03ba1..a65c6d7 100644
--- a/src/db/init.lua
+++ b/src/db/init.lua
@@ -1,85 +1,108 @@
--[[
mattata v2.0 - Migration Runner
Runs pending SQL migrations in order, wrapped in transactions.
Supports migrations from src/db/migrations/ AND plugin.migration fields.
]]
local migrations = {}
local logger = require('src.core.logger')
local migration_files = {
{ name = '001_initial_schema', path = 'src.db.migrations.001_initial_schema' },
{ name = '002_federation_tables', path = 'src.db.migrations.002_federation_tables' },
{ name = '003_statistics_tables', path = 'src.db.migrations.003_statistics_tables' },
- { name = '004_performance_indexes', path = 'src.db.migrations.004_performance_indexes' }
+ { name = '004_performance_indexes', path = 'src.db.migrations.004_performance_indexes' },
+ { name = '005_stored_procedures', path = 'src.db.migrations.005_stored_procedures' }
}
function migrations.run(db)
-- Create migrations tracking table
db.query([[
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
]])
-- Run each migration if not already applied
for _, mig in ipairs(migration_files) do
local applied = db.execute(
'SELECT 1 FROM schema_migrations WHERE name = $1',
{ mig.name }
)
if not applied or #applied == 0 then
logger.info('Running migration: %s', mig.name)
local ok, mod = pcall(require, mig.path)
if ok and type(mod) == 'table' and mod.up then
-- Wrap migration in a transaction
local begin_ok, begin_err = db.query('BEGIN')
if not begin_ok and begin_err then
logger.error('Failed to begin transaction for migration %s: %s', mig.name, tostring(begin_err))
os.exit(1)
end
local sql = mod.up()
local migration_ok = true
local migration_err = nil
- -- Split on semicolons and execute each statement
- for statement in sql:gmatch('[^;]+') do
- statement = statement:match('^%s*(.-)%s*$')
- if statement ~= '' then
- local result, err = db.query(statement)
- if not result and err then
- migration_ok = false
- migration_err = err
- break
+ -- Split on semicolons, respecting $$-delimited blocks
+ local statements = {}
+ local current = ''
+ local in_dollar = false
+ local i = 1
+ while i <= #sql do
+ if sql:sub(i, i + 1) == '$$' then
+ in_dollar = not in_dollar
+ current = current .. '$$'
+ i = i + 2
+ elseif sql:sub(i, i) == ';' and not in_dollar then
+ local trimmed = current:match('^%s*(.-)%s*$')
+ if trimmed ~= '' then
+ statements[#statements + 1] = trimmed
end
+ current = ''
+ i = i + 1
+ else
+ current = current .. sql:sub(i, i)
+ i = i + 1
+ end
+ end
+ local trimmed = current:match('^%s*(.-)%s*$')
+ if trimmed ~= '' then
+ statements[#statements + 1] = trimmed
+ end
+ for _, statement in ipairs(statements) do
+ local result, err = db.query(statement)
+ if not result and err then
+ migration_ok = false
+ migration_err = err
+ break
end
end
if not migration_ok then
logger.error('Migration %s failed: %s — rolling back', mig.name, tostring(migration_err))
db.query('ROLLBACK')
os.exit(1)
end
-- Record migration as applied using parameterized query
db.execute(
'INSERT INTO schema_migrations (name) VALUES ($1)',
{ mig.name }
)
db.query('COMMIT')
logger.info('Migration %s applied successfully', mig.name)
else
logger.error('Failed to load migration %s: %s', mig.name, tostring(mod))
os.exit(1)
end
else
logger.debug('Migration %s already applied', mig.name)
end
end
logger.info('All migrations up to date')
end
return migrations
diff --git a/src/db/migrations/005_stored_procedures.lua b/src/db/migrations/005_stored_procedures.lua
new file mode 100644
index 0000000..b14023d
--- /dev/null
+++ b/src/db/migrations/005_stored_procedures.lua
@@ -0,0 +1,918 @@
+--[[
+ Migration 005 - Stored Procedures
+ Creates PostgreSQL functions for all database operations.
+ All user-facing queries go through typed stored procedures to prevent SQL injection.
+ Parameters are strongly typed (BIGINT, TEXT, UUID, etc.) providing server-side validation.
+]]
+
+local migration = {}
+
+function migration.up()
+ return [[
+
+-- ============================================================
+-- USER / CHAT MANAGEMENT
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_upsert_user(
+ p_user_id BIGINT,
+ p_username TEXT,
+ p_first_name TEXT,
+ p_last_name TEXT,
+ p_language_code TEXT,
+ p_is_bot BOOLEAN,
+ p_last_seen TIMESTAMP WITH TIME ZONE
+) RETURNS void AS $$
+ INSERT INTO users (user_id, username, first_name, last_name, language_code, is_bot, last_seen)
+ VALUES (p_user_id, p_username, p_first_name, p_last_name, p_language_code, p_is_bot, p_last_seen)
+ ON CONFLICT (user_id) DO UPDATE SET
+ username = EXCLUDED.username,
+ first_name = EXCLUDED.first_name,
+ last_name = EXCLUDED.last_name,
+ language_code = EXCLUDED.language_code,
+ last_seen = EXCLUDED.last_seen;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_chat(
+ p_chat_id BIGINT,
+ p_title TEXT,
+ p_chat_type TEXT,
+ p_username TEXT
+) RETURNS void AS $$
+ INSERT INTO chats (chat_id, title, chat_type, username)
+ VALUES (p_chat_id, p_title, p_chat_type, p_username)
+ ON CONFLICT (chat_id) DO UPDATE SET
+ title = EXCLUDED.title,
+ chat_type = EXCLUDED.chat_type,
+ username = EXCLUDED.username;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_chat_member(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_last_seen TIMESTAMP WITH TIME ZONE
+) RETURNS void AS $$
+ INSERT INTO chat_members (chat_id, user_id, last_seen)
+ VALUES (p_chat_id, p_user_id, p_last_seen)
+ ON CONFLICT (chat_id, user_id) DO UPDATE SET
+ last_seen = EXCLUDED.last_seen;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- PERMISSIONS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_check_group_moderator(
+ p_chat_id BIGINT,
+ p_user_id BIGINT
+) RETURNS TABLE(exists_flag INTEGER) AS $$
+ SELECT 1 FROM chat_members
+ WHERE chat_id = p_chat_id AND user_id = p_user_id AND role = 'moderator';
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_check_trusted_user(
+ p_chat_id BIGINT,
+ p_user_id BIGINT
+) RETURNS TABLE(exists_flag INTEGER) AS $$
+ SELECT 1 FROM chat_members
+ WHERE chat_id = p_chat_id AND user_id = p_user_id AND role = 'trusted';
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_moderators(
+ p_chat_id BIGINT
+) RETURNS TABLE(user_id BIGINT) AS $$
+ SELECT cm.user_id FROM chat_members cm
+ WHERE cm.chat_id = p_chat_id AND cm.role = 'moderator';
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- CHAT SETTINGS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_chat_setting(
+ p_chat_id BIGINT,
+ p_key TEXT
+) RETURNS TABLE(value TEXT) AS $$
+ SELECT cs.value FROM chat_settings cs
+ WHERE cs.chat_id = p_chat_id AND cs.key = p_key;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_chat_setting(
+ p_chat_id BIGINT,
+ p_key TEXT,
+ p_value TEXT
+) RETURNS void AS $$
+ INSERT INTO chat_settings (chat_id, key, value)
+ VALUES (p_chat_id, p_key, p_value)
+ ON CONFLICT (chat_id, key) DO UPDATE SET
+ value = EXCLUDED.value;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_disable_chat_setting(
+ p_chat_id BIGINT,
+ p_key TEXT
+) RETURNS void AS $$
+ UPDATE chat_settings SET value = 'false'
+ WHERE chat_id = p_chat_id AND key = p_key;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_chat_setting(
+ p_chat_id BIGINT,
+ p_key TEXT
+) RETURNS void AS $$
+ DELETE FROM chat_settings
+ WHERE chat_id = p_chat_id AND key = p_key;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_chat_settings_like(
+ p_chat_id BIGINT,
+ p_key_pattern TEXT
+) RETURNS TABLE(key TEXT, value TEXT) AS $$
+ SELECT cs.key, cs.value FROM chat_settings cs
+ WHERE cs.chat_id = p_chat_id AND cs.key LIKE p_key_pattern;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- ROLE MANAGEMENT
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_set_member_role(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_role TEXT
+) RETURNS void AS $$
+ INSERT INTO chat_members (chat_id, user_id, role)
+ VALUES (p_chat_id, p_user_id, p_role)
+ ON CONFLICT (chat_id, user_id) DO UPDATE SET
+ role = EXCLUDED.role;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_reset_member_role(
+ p_chat_id BIGINT,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ UPDATE chat_members SET role = 'member'
+ WHERE chat_id = p_chat_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_remove_allowlisted(
+ p_chat_id BIGINT,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ UPDATE chat_members SET role = 'member'
+ WHERE chat_id = p_chat_id AND user_id = p_user_id AND role = 'allowlisted';
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_allowlisted_users(
+ p_chat_id BIGINT
+) RETURNS TABLE(user_id BIGINT) AS $$
+ SELECT cm.user_id FROM chat_members cm
+ WHERE cm.chat_id = p_chat_id AND cm.role = 'allowlisted';
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- BANS / WARNINGS / ADMIN ACTIONS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_insert_ban(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_banned_by BIGINT,
+ p_reason TEXT
+) RETURNS void AS $$
+ INSERT INTO bans (chat_id, user_id, banned_by, reason)
+ VALUES (p_chat_id, p_user_id, p_banned_by, p_reason);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_tempban(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_banned_by BIGINT,
+ p_expires_at TIMESTAMP WITH TIME ZONE
+) RETURNS void AS $$
+ INSERT INTO bans (chat_id, user_id, banned_by, expires_at)
+ VALUES (p_chat_id, p_user_id, p_banned_by, p_expires_at);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_warning(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_warned_by BIGINT,
+ p_reason TEXT
+) RETURNS void AS $$
+ INSERT INTO warnings (chat_id, user_id, warned_by, reason)
+ VALUES (p_chat_id, p_user_id, p_warned_by, p_reason);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_log_admin_action(
+ p_chat_id BIGINT,
+ p_admin_id BIGINT,
+ p_target_id BIGINT,
+ p_action TEXT,
+ p_reason TEXT
+) RETURNS void AS $$
+ INSERT INTO admin_actions (chat_id, admin_id, target_id, action, reason)
+ VALUES (p_chat_id, p_admin_id, p_target_id, p_action, p_reason);
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- BLOCKLIST
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_blocklist(
+ p_chat_id BIGINT
+) RETURNS TABLE(user_id BIGINT, reason TEXT, created_at TIMESTAMP WITH TIME ZONE) AS $$
+ SELECT gb.user_id, gb.reason, gb.created_at FROM group_blocklist gb
+ WHERE gb.chat_id = p_chat_id ORDER BY gb.created_at DESC;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_blocklist_entry(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_reason TEXT
+) RETURNS void AS $$
+ INSERT INTO group_blocklist (chat_id, user_id, reason)
+ VALUES (p_chat_id, p_user_id, p_reason)
+ ON CONFLICT (chat_id, user_id) DO UPDATE SET
+ reason = EXCLUDED.reason;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_blocklist_entry(
+ p_chat_id BIGINT,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM group_blocklist
+ WHERE chat_id = p_chat_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FILTERS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_filter(
+ p_chat_id BIGINT,
+ p_pattern TEXT
+) RETURNS TABLE(id INTEGER) AS $$
+ SELECT f.id FROM filters f
+ WHERE f.chat_id = p_chat_id AND f.pattern = p_pattern;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_filters(
+ p_chat_id BIGINT
+) RETURNS TABLE(pattern TEXT, action VARCHAR(20)) AS $$
+ SELECT f.pattern, f.action FROM filters f
+ WHERE f.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_filters_ordered(
+ p_chat_id BIGINT
+) RETURNS TABLE(id INTEGER, pattern TEXT, action VARCHAR(20), created_at TIMESTAMP WITH TIME ZONE) AS $$
+ SELECT f.id, f.pattern, f.action, f.created_at FROM filters f
+ WHERE f.chat_id = p_chat_id ORDER BY f.created_at;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_update_filter_action(
+ p_action TEXT,
+ p_chat_id BIGINT,
+ p_pattern TEXT
+) RETURNS void AS $$
+ UPDATE filters SET action = p_action
+ WHERE chat_id = p_chat_id AND pattern = p_pattern;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_filter(
+ p_chat_id BIGINT,
+ p_pattern TEXT,
+ p_action TEXT,
+ p_created_by BIGINT
+) RETURNS void AS $$
+ INSERT INTO filters (chat_id, pattern, action, created_by)
+ VALUES (p_chat_id, p_pattern, p_action, p_created_by);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_filter_by_pattern(
+ p_chat_id BIGINT,
+ p_pattern TEXT
+) RETURNS BIGINT AS $$
+ WITH deleted AS (
+ DELETE FROM filters
+ WHERE chat_id = p_chat_id AND pattern = p_pattern
+ RETURNING id
+ )
+ SELECT COUNT(*) FROM deleted;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_filter_by_id(
+ p_id INTEGER
+) RETURNS void AS $$
+ DELETE FROM filters WHERE id = p_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- TRIGGERS (auto-response)
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_triggers(
+ p_chat_id BIGINT
+) RETURNS TABLE(pattern TEXT, response TEXT, is_media BOOLEAN, file_id TEXT) AS $$
+ SELECT t.pattern, t.response, t.is_media, t.file_id FROM triggers t
+ WHERE t.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_triggers_full(
+ p_chat_id BIGINT
+) RETURNS TABLE(id INTEGER, pattern TEXT, response TEXT, created_by BIGINT, created_at TIMESTAMP WITH TIME ZONE) AS $$
+ SELECT t.id, t.pattern, t.response, t.created_by, t.created_at FROM triggers t
+ WHERE t.chat_id = p_chat_id ORDER BY t.created_at;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_triggers_ordered(
+ p_chat_id BIGINT
+) RETURNS TABLE(id INTEGER, pattern TEXT) AS $$
+ SELECT t.id, t.pattern FROM triggers t
+ WHERE t.chat_id = p_chat_id ORDER BY t.created_at;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_check_trigger_exists(
+ p_chat_id BIGINT,
+ p_pattern TEXT
+) RETURNS TABLE(id INTEGER) AS $$
+ SELECT t.id FROM triggers t
+ WHERE t.chat_id = p_chat_id AND t.pattern = p_pattern;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_update_trigger_response(
+ p_response TEXT,
+ p_chat_id BIGINT,
+ p_pattern TEXT
+) RETURNS void AS $$
+ UPDATE triggers SET response = p_response
+ WHERE chat_id = p_chat_id AND pattern = p_pattern;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_trigger(
+ p_chat_id BIGINT,
+ p_pattern TEXT,
+ p_response TEXT,
+ p_created_by BIGINT
+) RETURNS void AS $$
+ INSERT INTO triggers (chat_id, pattern, response, created_by)
+ VALUES (p_chat_id, p_pattern, p_response, p_created_by);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_trigger_by_id(
+ p_id INTEGER
+) RETURNS void AS $$
+ DELETE FROM triggers WHERE id = p_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- WELCOME MESSAGES
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_welcome_message(
+ p_chat_id BIGINT
+) RETURNS TABLE(message TEXT) AS $$
+ SELECT wm.message FROM welcome_messages wm
+ WHERE wm.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_welcome_message(
+ p_chat_id BIGINT,
+ p_message TEXT
+) RETURNS void AS $$
+ INSERT INTO welcome_messages (chat_id, message)
+ VALUES (p_chat_id, p_message)
+ ON CONFLICT (chat_id) DO UPDATE SET
+ message = EXCLUDED.message;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- RULES
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_rules(
+ p_chat_id BIGINT
+) RETURNS TABLE(rules_text TEXT) AS $$
+ SELECT r.rules_text FROM rules r
+ WHERE r.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_rules(
+ p_chat_id BIGINT,
+ p_rules_text TEXT
+) RETURNS void AS $$
+ INSERT INTO rules (chat_id, rules_text)
+ VALUES (p_chat_id, p_rules_text)
+ ON CONFLICT (chat_id) DO UPDATE SET
+ rules_text = EXCLUDED.rules_text,
+ updated_at = NOW();
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- ALLOWED LINKS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_allowed_links(
+ p_chat_id BIGINT
+) RETURNS TABLE(link VARCHAR(255)) AS $$
+ SELECT al.link FROM allowed_links al
+ WHERE al.chat_id = p_chat_id ORDER BY al.link;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_check_allowed_link(
+ p_chat_id BIGINT,
+ p_link TEXT
+) RETURNS TABLE(exists_flag INTEGER) AS $$
+ SELECT 1 FROM allowed_links
+ WHERE chat_id = p_chat_id AND link = p_link;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_allowed_link(
+ p_chat_id BIGINT,
+ p_link TEXT
+) RETURNS void AS $$
+ INSERT INTO allowed_links (chat_id, link)
+ VALUES (p_chat_id, p_link);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_allowed_link(
+ p_chat_id BIGINT,
+ p_link TEXT
+) RETURNS void AS $$
+ DELETE FROM allowed_links
+ WHERE chat_id = p_chat_id AND link = p_link;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- NICKNAME
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_nickname(
+ p_user_id BIGINT
+) RETURNS TABLE(nickname VARCHAR(128)) AS $$
+ SELECT u.nickname FROM users u
+ WHERE u.user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_set_nickname(
+ p_user_id BIGINT,
+ p_nickname TEXT
+) RETURNS void AS $$
+ UPDATE users SET nickname = p_nickname
+ WHERE user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_clear_nickname(
+ p_user_id BIGINT
+) RETURNS void AS $$
+ UPDATE users SET nickname = NULL
+ WHERE user_id = p_user_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- USER LOCATIONS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_user_location(
+ p_user_id BIGINT
+) RETURNS TABLE(latitude DOUBLE PRECISION, longitude DOUBLE PRECISION, address TEXT) AS $$
+ SELECT ul.latitude, ul.longitude, ul.address FROM user_locations ul
+ WHERE ul.user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_user_location(
+ p_user_id BIGINT,
+ p_latitude DOUBLE PRECISION,
+ p_longitude DOUBLE PRECISION,
+ p_address TEXT
+) RETURNS void AS $$
+ INSERT INTO user_locations (user_id, latitude, longitude, address, updated_at)
+ VALUES (p_user_id, p_latitude, p_longitude, p_address, NOW())
+ ON CONFLICT (user_id) DO UPDATE SET
+ latitude = EXCLUDED.latitude,
+ longitude = EXCLUDED.longitude,
+ address = EXCLUDED.address,
+ updated_at = NOW();
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- SAVED NOTES
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_list_notes(
+ p_chat_id BIGINT
+) RETURNS TABLE(note_name VARCHAR(64)) AS $$
+ SELECT sn.note_name FROM saved_notes sn
+ WHERE sn.chat_id = p_chat_id ORDER BY sn.note_name;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_note(
+ p_chat_id BIGINT,
+ p_note_name TEXT
+) RETURNS TABLE(content TEXT, content_type VARCHAR(20), file_id TEXT) AS $$
+ SELECT sn.content, sn.content_type, sn.file_id FROM saved_notes sn
+ WHERE sn.chat_id = p_chat_id AND sn.note_name = p_note_name;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_upsert_note(
+ p_chat_id BIGINT,
+ p_note_name TEXT,
+ p_content TEXT,
+ p_content_type TEXT,
+ p_file_id TEXT,
+ p_created_by BIGINT
+) RETURNS void AS $$
+ INSERT INTO saved_notes (chat_id, note_name, content, content_type, file_id, created_by)
+ VALUES (p_chat_id, p_note_name, p_content, p_content_type, p_file_id, p_created_by)
+ ON CONFLICT (chat_id, note_name) DO UPDATE SET
+ content = EXCLUDED.content,
+ content_type = EXCLUDED.content_type,
+ file_id = EXCLUDED.file_id,
+ created_by = EXCLUDED.created_by;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- STATISTICS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_flush_message_stats(
+ p_chat_id BIGINT,
+ p_user_id BIGINT,
+ p_date DATE,
+ p_count INTEGER
+) RETURNS void AS $$
+ INSERT INTO message_stats (chat_id, user_id, date, message_count)
+ VALUES (p_chat_id, p_user_id, p_date, p_count)
+ ON CONFLICT (chat_id, user_id, date) DO UPDATE SET
+ message_count = message_stats.message_count + p_count;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_flush_command_stats(
+ p_chat_id BIGINT,
+ p_command TEXT,
+ p_date DATE,
+ p_count INTEGER
+) RETURNS void AS $$
+ INSERT INTO command_stats (chat_id, command, date, use_count)
+ VALUES (p_chat_id, p_command, p_date, p_count)
+ ON CONFLICT (chat_id, command, date) DO UPDATE SET
+ use_count = command_stats.use_count + p_count;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_top_users(
+ p_chat_id BIGINT
+) RETURNS TABLE(user_id BIGINT, total BIGINT, first_name VARCHAR(255), last_name VARCHAR(255), username VARCHAR(255)) AS $$
+ SELECT ms.user_id, SUM(ms.message_count)::BIGINT AS total,
+ u.first_name, u.last_name, u.username
+ FROM message_stats ms
+ LEFT JOIN users u ON ms.user_id = u.user_id
+ WHERE ms.chat_id = p_chat_id
+ GROUP BY ms.user_id, u.first_name, u.last_name, u.username
+ ORDER BY total DESC
+ LIMIT 10;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_total_messages(
+ p_chat_id BIGINT
+) RETURNS TABLE(total BIGINT) AS $$
+ SELECT SUM(message_count)::BIGINT AS total FROM message_stats
+ WHERE chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_unique_users(
+ p_chat_id BIGINT
+) RETURNS TABLE(total BIGINT) AS $$
+ SELECT COUNT(DISTINCT user_id)::BIGINT AS total FROM message_stats
+ WHERE chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_reset_message_stats(
+ p_chat_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM message_stats WHERE chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_top_commands(
+ p_chat_id BIGINT
+) RETURNS TABLE(command VARCHAR(64), total BIGINT) AS $$
+ SELECT cs.command, SUM(cs.use_count)::BIGINT AS total
+ FROM command_stats cs
+ WHERE cs.chat_id = p_chat_id
+ GROUP BY cs.command
+ ORDER BY total DESC
+ LIMIT 10;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_reset_command_stats(
+ p_chat_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM command_stats WHERE chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- GROUPS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_list_groups()
+RETURNS TABLE(chat_id BIGINT, title VARCHAR(255), username VARCHAR(255)) AS $$
+ SELECT c.chat_id, c.title, c.username FROM chats c
+ WHERE c.chat_type IN ('group', 'supergroup')
+ ORDER BY c.title LIMIT 50;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_search_groups(
+ p_search TEXT
+) RETURNS TABLE(chat_id BIGINT, title VARCHAR(255), username VARCHAR(255)) AS $$
+ SELECT c.chat_id, c.title, c.username FROM chats c
+ WHERE c.chat_type IN ('group', 'supergroup')
+ AND LOWER(c.title) LIKE p_search
+ ORDER BY c.title LIMIT 50;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- INFO / COUNTS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_count_users()
+RETURNS TABLE(count BIGINT) AS $$
+ SELECT COUNT(*)::BIGINT FROM users;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_count_chats()
+RETURNS TABLE(count BIGINT) AS $$
+ SELECT COUNT(*)::BIGINT FROM chats;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FEDERATION - CORE
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_chat_federation_id(
+ p_chat_id BIGINT
+) RETURNS TABLE(federation_id UUID) AS $$
+ SELECT fc.federation_id FROM federation_chats fc
+ WHERE fc.chat_id = p_chat_id LIMIT 1;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_chat_federation(
+ p_chat_id BIGINT
+) RETURNS TABLE(id UUID, name VARCHAR(255), owner_id BIGINT) AS $$
+ SELECT f.id, f.name, f.owner_id
+ FROM federations f
+ JOIN federation_chats fc ON f.id = fc.federation_id
+ WHERE fc.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_federation(
+ p_federation_id UUID
+) RETURNS TABLE(id UUID, name VARCHAR(255), owner_id BIGINT, created_at TIMESTAMP WITH TIME ZONE) AS $$
+ SELECT f.id, f.name, f.owner_id, f.created_at FROM federations f
+ WHERE f.id = p_federation_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_federation_basic(
+ p_federation_id UUID
+) RETURNS TABLE(id UUID, name VARCHAR(255)) AS $$
+ SELECT f.id, f.name FROM federations f
+ WHERE f.id = p_federation_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_federation_owner(
+ p_federation_id UUID
+) RETURNS TABLE(name VARCHAR(255), owner_id BIGINT) AS $$
+ SELECT f.name, f.owner_id FROM federations f
+ WHERE f.id = p_federation_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_count_user_federations(
+ p_user_id BIGINT
+) RETURNS TABLE(count BIGINT) AS $$
+ SELECT COUNT(*)::BIGINT FROM federations
+ WHERE owner_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_create_federation(
+ p_name TEXT,
+ p_owner_id BIGINT
+) RETURNS TABLE(id UUID) AS $$
+ INSERT INTO federations (name, owner_id)
+ VALUES (p_name, p_owner_id)
+ RETURNING id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_federation(
+ p_federation_id UUID
+) RETURNS void AS $$
+ DELETE FROM federations WHERE id = p_federation_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FEDERATION - CHATS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_chat_federation_joined(
+ p_chat_id BIGINT
+) RETURNS TABLE(id UUID, name VARCHAR(255)) AS $$
+ SELECT f.id, f.name
+ FROM federations f
+ JOIN federation_chats fc ON f.id = fc.federation_id
+ WHERE fc.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_join_federation(
+ p_federation_id UUID,
+ p_chat_id BIGINT,
+ p_joined_by BIGINT
+) RETURNS void AS $$
+ INSERT INTO federation_chats (federation_id, chat_id, joined_by)
+ VALUES (p_federation_id, p_chat_id, p_joined_by);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_leave_federation(
+ p_federation_id UUID,
+ p_chat_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM federation_chats
+ WHERE federation_id = p_federation_id AND chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_federation_chats(
+ p_federation_id UUID
+) RETURNS TABLE(chat_id BIGINT) AS $$
+ SELECT fc.chat_id FROM federation_chats fc
+ WHERE fc.federation_id = p_federation_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FEDERATION - BANS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_check_federation_ban(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS TABLE(reason TEXT) AS $$
+ SELECT fb.reason FROM federation_bans fb
+ WHERE fb.federation_id = p_federation_id AND fb.user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_check_federation_ban_exists(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS TABLE(exists_flag INTEGER) AS $$
+ SELECT 1 FROM federation_bans
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_federation_ban(
+ p_federation_id UUID,
+ p_user_id BIGINT,
+ p_reason TEXT,
+ p_banned_by BIGINT
+) RETURNS void AS $$
+ INSERT INTO federation_bans (federation_id, user_id, reason, banned_by)
+ VALUES (p_federation_id, p_user_id, p_reason, p_banned_by);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_update_federation_ban(
+ p_reason TEXT,
+ p_banned_by BIGINT,
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ UPDATE federation_bans SET reason = p_reason, banned_by = p_banned_by, banned_at = NOW()
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_federation_ban(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM federation_bans
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_fban_info_group(
+ p_user_id BIGINT,
+ p_chat_id BIGINT
+) RETURNS TABLE(reason TEXT, banned_by BIGINT, banned_at TIMESTAMP WITH TIME ZONE, name VARCHAR(255), id UUID) AS $$
+ SELECT fb.reason, fb.banned_by, fb.banned_at, f.name, f.id
+ FROM federation_bans fb
+ JOIN federations f ON fb.federation_id = f.id
+ JOIN federation_chats fc ON f.id = fc.federation_id
+ WHERE fb.user_id = p_user_id AND fc.chat_id = p_chat_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_fban_info_all(
+ p_user_id BIGINT
+) RETURNS TABLE(reason TEXT, banned_by BIGINT, banned_at TIMESTAMP WITH TIME ZONE, name VARCHAR(255), id UUID) AS $$
+ SELECT fb.reason, fb.banned_by, fb.banned_at, f.name, f.id
+ FROM federation_bans fb
+ JOIN federations f ON fb.federation_id = f.id
+ WHERE fb.user_id = p_user_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FEDERATION - ADMINS
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_check_federation_admin(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS TABLE(exists_flag INTEGER) AS $$
+ SELECT 1 FROM federation_admins
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_federation_admin(
+ p_federation_id UUID,
+ p_user_id BIGINT,
+ p_promoted_by BIGINT
+) RETURNS void AS $$
+ INSERT INTO federation_admins (federation_id, user_id, promoted_by)
+ VALUES (p_federation_id, p_user_id, p_promoted_by);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_federation_admin(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM federation_admins
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_federation_admins(
+ p_federation_id UUID
+) RETURNS TABLE(user_id BIGINT, promoted_at TIMESTAMP WITH TIME ZONE) AS $$
+ SELECT fa.user_id, fa.promoted_at FROM federation_admins fa
+ WHERE fa.federation_id = p_federation_id ORDER BY fa.promoted_at ASC;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FEDERATION - ALLOWLIST
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_check_federation_allowlist(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS TABLE(exists_flag INTEGER) AS $$
+ SELECT 1 FROM federation_allowlist
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_insert_federation_allowlist(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ INSERT INTO federation_allowlist (federation_id, user_id)
+ VALUES (p_federation_id, p_user_id);
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_delete_federation_allowlist(
+ p_federation_id UUID,
+ p_user_id BIGINT
+) RETURNS void AS $$
+ DELETE FROM federation_allowlist
+ WHERE federation_id = p_federation_id AND user_id = p_user_id;
+$$ LANGUAGE sql;
+
+-- ============================================================
+-- FEDERATION - COUNTS / LISTING
+-- ============================================================
+
+CREATE OR REPLACE FUNCTION sp_get_federation_counts(
+ p_federation_id UUID
+) RETURNS TABLE(admin_count BIGINT, chat_count BIGINT, ban_count BIGINT) AS $$
+ SELECT
+ (SELECT COUNT(*) FROM federation_admins WHERE federation_id = p_federation_id)::BIGINT,
+ (SELECT COUNT(*) FROM federation_chats WHERE federation_id = p_federation_id)::BIGINT,
+ (SELECT COUNT(*) FROM federation_bans WHERE federation_id = p_federation_id)::BIGINT;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_owned_federations(
+ p_user_id BIGINT
+) RETURNS TABLE(id UUID, name VARCHAR(255), chat_count BIGINT, ban_count BIGINT) AS $$
+ SELECT f.id, f.name,
+ (SELECT COUNT(*) FROM federation_chats WHERE federation_id = f.id)::BIGINT AS chat_count,
+ (SELECT COUNT(*) FROM federation_bans WHERE federation_id = f.id)::BIGINT AS ban_count
+ FROM federations f
+ WHERE f.owner_id = p_user_id
+ ORDER BY f.created_at ASC;
+$$ LANGUAGE sql;
+
+CREATE OR REPLACE FUNCTION sp_get_admin_federations(
+ p_user_id BIGINT
+) RETURNS TABLE(id UUID, name VARCHAR(255), owner_id BIGINT, chat_count BIGINT, ban_count BIGINT) AS $$
+ SELECT f.id, f.name, f.owner_id,
+ (SELECT COUNT(*) FROM federation_chats WHERE federation_id = f.id)::BIGINT AS chat_count,
+ (SELECT COUNT(*) FROM federation_bans WHERE federation_id = f.id)::BIGINT AS ban_count
+ FROM federations f
+ JOIN federation_admins fa ON f.id = fa.federation_id
+ WHERE fa.user_id = p_user_id AND f.owner_id != p_user_id
+ ORDER BY fa.promoted_at ASC;
+$$ LANGUAGE sql;
+
+ ]]
+end
+
+return migration
diff --git a/src/middleware/federation.lua b/src/middleware/federation.lua
index 3beda5d..ddda686 100644
--- a/src/middleware/federation.lua
+++ b/src/middleware/federation.lua
@@ -1,85 +1,76 @@
--[[
mattata v2.0 - Federation Middleware
Checks if incoming users are banned in the chat's federation.
Uses PostgreSQL as source of truth with Redis caching.
]]
local federation = {}
federation.name = 'federation'
local session = require('src.core.session')
local logger = require('src.core.logger')
function federation.run(ctx, message)
if not ctx.is_group or not message.from then
return ctx, true
end
-- Global admins bypass federation bans
if ctx.is_global_admin then
return ctx, true
end
local chat_id = message.chat.id
local user_id = message.from.id
-- Check if this chat belongs to a federation (cached)
local fed_id = session.get_cached_setting(chat_id, 'federation_id', function()
- local result = ctx.db.execute(
- 'SELECT federation_id FROM federation_chats WHERE chat_id = $1 LIMIT 1',
- { chat_id }
- )
+ local result = ctx.db.call('sp_get_chat_federation_id', { chat_id })
if result and #result > 0 then
return result[1].federation_id
end
return nil
end, 300)
if not fed_id then
return ctx, true
end
ctx.federation_id = fed_id
-- Check if user is federation-banned (cached briefly)
local ban_key = string.format('fban:%s:%s', fed_id, user_id)
local is_banned = ctx.redis.get(ban_key)
if is_banned == nil then
- local ban = ctx.db.execute(
- 'SELECT reason FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
- { fed_id, user_id }
- )
+ local ban = ctx.db.call('sp_check_federation_ban', { fed_id, user_id })
if ban and #ban > 0 then
ctx.redis.setex(ban_key, 300, ban[1].reason or 'Federation ban')
is_banned = ban[1].reason or 'Federation ban'
else
ctx.redis.setex(ban_key, 300, '__not_banned__')
is_banned = '__not_banned__'
end
end
if is_banned and is_banned ~= '__not_banned__' then
-- Check allowlist
local allowlist_key = string.format('fallowlist:%s:%s', fed_id, user_id)
local is_allowed = ctx.redis.get(allowlist_key)
if is_allowed == nil then
- local allowed = ctx.db.execute(
- 'SELECT 1 FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
- { fed_id, user_id }
- )
+ local allowed = ctx.db.call('sp_check_federation_allowlist', { fed_id, user_id })
is_allowed = (allowed and #allowed > 0) and '1' or '0'
ctx.redis.setex(allowlist_key, 300, is_allowed)
end
if is_allowed ~= '1' then
pcall(function()
ctx.api.ban_chat_member(chat_id, user_id)
end)
logger.info('Federation ban enforced: user %d in chat %d (fed %s)', user_id, chat_id, fed_id)
return ctx, false
end
end
return ctx, true
end
return federation
diff --git a/src/middleware/stats.lua b/src/middleware/stats.lua
index 9990b94..b25e061 100644
--- a/src/middleware/stats.lua
+++ b/src/middleware/stats.lua
@@ -1,102 +1,94 @@
--[[
mattata v2.0 - Stats Middleware
Increments Redis counters for message and command statistics.
Counters are flushed to PostgreSQL every 5 minutes via cron.
]]
local stats_mw = {}
stats_mw.name = 'stats'
local logger = require('src.core.logger')
function stats_mw.run(ctx, message)
if not message.from or not message.chat then
return ctx, true
end
local chat_id = message.chat.id
local user_id = message.from.id
local date = os.date('!%Y-%m-%d')
-- Increment message counter in Redis
local msg_key = string.format('stats:msg:%s:%s:%s', chat_id, date, user_id)
pcall(function()
local count = ctx.redis.incr(msg_key)
if count == 1 then
ctx.redis.expire(msg_key, 86400) -- 24h TTL
end
end)
-- Track command usage
if message.text and message.text:match('^[/!#]') then
local cmd = message.text:match('^[/!#]([%w_]+)')
if cmd then
local cmd_key = string.format('stats:cmd:%s:%s:%s', cmd:lower(), chat_id, date)
pcall(function()
local count = ctx.redis.incr(cmd_key)
if count == 1 then
ctx.redis.expire(cmd_key, 86400)
end
end)
end
end
return ctx, true
end
-- Cron job: flush Redis stats counters to PostgreSQL
-- Called from the stats flush plugin every 5 minutes
function stats_mw.flush(db, redis)
-- Flush message stats
local msg_keys = redis.scan('stats:msg:*')
local flushed = 0
for _, key in ipairs(msg_keys) do
local count = tonumber(redis.get(key))
if count and count > 0 then
-- Parse key: stats:msg:{chat_id}:{date}:{user_id}
local chat_id, date, user_id = key:match('stats:msg:(%-?%d+):(%d%d%d%d%-%d%d%-%d%d):(%d+)')
if chat_id and date and user_id then
pcall(function()
- db.execute(
- [[INSERT INTO message_stats (chat_id, user_id, date, message_count)
- VALUES ($1, $2, $3, $4)
- ON CONFLICT (chat_id, user_id, date) DO UPDATE SET
- message_count = message_stats.message_count + $4]],
- { tonumber(chat_id), tonumber(user_id), date, count }
- )
+ db.call('sp_flush_message_stats', {
+ tonumber(chat_id), tonumber(user_id), date, count
+ })
end)
redis.del(key)
flushed = flushed + 1
end
end
end
-- Flush command stats
local cmd_keys = redis.scan('stats:cmd:*')
for _, key in ipairs(cmd_keys) do
local count = tonumber(redis.get(key))
if count and count > 0 then
-- Parse key: stats:cmd:{command}:{chat_id}:{date}
local command, chat_id, date = key:match('stats:cmd:([%w_]+):(%-?%d+):(%d%d%d%d%-%d%d%-%d%d)')
if command and chat_id and date then
pcall(function()
- db.execute(
- [[INSERT INTO command_stats (chat_id, command, date, use_count)
- VALUES ($1, $2, $3, $4)
- ON CONFLICT (chat_id, command, date) DO UPDATE SET
- use_count = command_stats.use_count + $4]],
- { tonumber(chat_id), command, date, count }
- )
+ db.call('sp_flush_command_stats', {
+ tonumber(chat_id), command, date, count
+ })
end)
redis.del(key)
flushed = flushed + 1
end
end
end
if flushed > 0 then
logger.info('Flushed %d stats counters to PostgreSQL', flushed)
end
end
return stats_mw
diff --git a/src/middleware/user_tracker.lua b/src/middleware/user_tracker.lua
index 4a10146..22bcdb0 100644
--- a/src/middleware/user_tracker.lua
+++ b/src/middleware/user_tracker.lua
@@ -1,81 +1,76 @@
--[[
mattata v2.0 - User Tracker Middleware
Upserts user and chat information to PostgreSQL with Redis-based debouncing.
Uses a 60s dedup key per user+chat to reduce DB writes by ~95%.
]]
local user_tracker = {}
user_tracker.name = 'user_tracker'
function user_tracker.run(ctx, message)
if not message.from then
return ctx, true
end
local user = message.from
local user_id = user.id
local chat_id = message.chat and message.chat.id
-- Debounce: skip DB upserts if we've seen this user+chat in the last 60s
local dedup_key = string.format('seen:%s:%s', user_id, chat_id or 'private')
local already_seen = ctx.redis.exists(dedup_key)
if already_seen == 1 or already_seen == true then
-- Still update username->id mapping (cheap Redis SET)
if user.username then
ctx.redis.set('username:' .. user.username:lower(), user_id)
end
return ctx, true
end
-- Set dedup key with 60s TTL
ctx.redis.setex(dedup_key, 60, '1')
- -- Upsert user to PostgreSQL
+ -- upsert user to postgresql
+ local now = os.date('!%Y-%m-%d %H:%M:%S')
pcall(function()
- ctx.db.upsert('users', {
- user_id = user_id,
- username = user.username and user.username:lower() or nil,
- first_name = user.first_name,
- last_name = user.last_name,
- language_code = user.language_code,
- is_bot = user.is_bot or false,
- last_seen = os.date('!%Y-%m-%d %H:%M:%S')
- }, { 'user_id' }, {
- 'username', 'first_name', 'last_name', 'language_code', 'last_seen'
- })
+ ctx.db.call('sp_upsert_user', table.pack(
+ user_id,
+ user.username and user.username:lower() or nil,
+ user.first_name,
+ user.last_name,
+ user.language_code,
+ user.is_bot or false,
+ now
+ ))
end)
- -- Upsert chat to PostgreSQL (for groups)
+ -- upsert chat to postgresql (for groups)
if chat_id and message.chat.type ~= 'private' then
pcall(function()
- ctx.db.upsert('chats', {
- chat_id = chat_id,
- title = message.chat.title,
- chat_type = message.chat.type,
- username = message.chat.username and message.chat.username:lower() or nil
- }, { 'chat_id' }, {
- 'title', 'chat_type', 'username'
- })
+ ctx.db.call('sp_upsert_chat', table.pack(
+ chat_id,
+ message.chat.title,
+ message.chat.type,
+ message.chat.username and message.chat.username:lower() or nil
+ ))
end)
- -- Track user<->chat membership
+ -- track user<->chat membership
pcall(function()
- ctx.db.upsert('chat_members', {
- chat_id = chat_id,
- user_id = user_id,
- last_seen = os.date('!%Y-%m-%d %H:%M:%S')
- }, { 'chat_id', 'user_id' }, {
- 'last_seen'
+ ctx.db.call('sp_upsert_chat_member', {
+ chat_id,
+ user_id,
+ now
})
end)
end
-- Keep Redis username->id mapping for quick lookups
if user.username then
ctx.redis.set('username:' .. user.username:lower(), user_id)
end
return ctx, true
end
return user_tracker
diff --git a/src/plugins/admin/addtrigger.lua b/src/plugins/admin/addtrigger.lua
index d69de24..033b060 100644
--- a/src/plugins/admin/addtrigger.lua
+++ b/src/plugins/admin/addtrigger.lua
@@ -1,134 +1,117 @@
--[[
mattata v2.0 - Add Trigger Plugin
]]
local plugin = {}
plugin.name = 'addtrigger'
plugin.category = 'admin'
plugin.description = 'Add a trigger (auto-response pattern)'
plugin.commands = { 'addtrigger' }
plugin.help = '/addtrigger <pattern> <response> - Adds a trigger. Use /deltrigger <number> to remove.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
return api.send_message(message.chat.id,
'Usage: /addtrigger <pattern> <response>\n\n'
.. 'The pattern is a Lua pattern that will be matched against incoming messages. '
.. 'When matched, the response is sent.')
end
- -- If used with /deltrigger, handle deletion
+ -- if used with /deltrigger, handle deletion
if message.command == 'deltrigger' then
local index = tonumber(message.args)
if not index then
return api.send_message(message.chat.id, 'Usage: /deltrigger <number>')
end
- local triggers = ctx.db.execute(
- 'SELECT id, pattern FROM triggers WHERE chat_id = $1 ORDER BY created_at',
- { message.chat.id }
- )
+ local triggers = ctx.db.call('sp_get_triggers_ordered', { message.chat.id })
if not triggers or not triggers[index] then
return api.send_message(message.chat.id, 'Invalid trigger number. Use /triggers to see the list.')
end
- ctx.db.execute('DELETE FROM triggers WHERE id = $1', { triggers[index].id })
- -- Invalidate trigger cache
+ ctx.db.call('sp_delete_trigger_by_id', { triggers[index].id })
+ -- invalidate trigger cache
require('src.core.session').invalidate_cached_list(message.chat.id, 'triggers')
return api.send_message(message.chat.id, string.format(
'Trigger <code>%s</code> has been removed.',
tools.escape_html(triggers[index].pattern)
), 'html')
end
- -- Parse pattern and response (split on first newline or first space after the pattern)
+ -- parse pattern and response (split on first newline or first space after the pattern)
local pattern, response
if message.args:match('\n') then
pattern, response = message.args:match('^(.-)%s*\n%s*(.+)$')
else
pattern, response = message.args:match('^(%S+)%s+(.+)$')
end
if not pattern or not response then
return api.send_message(message.chat.id, 'Usage: /addtrigger <pattern> <response>')
end
pattern = pattern:match('^%s*(.-)%s*$')
response = response:match('^%s*(.-)%s*$')
- -- Validate pattern
+ -- validate pattern
local ok = pcall(string.match, '', pattern)
if not ok then
return api.send_message(message.chat.id, 'Invalid Lua pattern. Please check your syntax.')
end
- -- Check for duplicate
- local existing = ctx.db.execute(
- 'SELECT id FROM triggers WHERE chat_id = $1 AND pattern = $2',
- { message.chat.id, pattern }
- )
+ -- check for duplicate
+ local existing = ctx.db.call('sp_check_trigger_exists', { message.chat.id, pattern })
if existing and #existing > 0 then
- ctx.db.execute(
- 'UPDATE triggers SET response = $1 WHERE chat_id = $2 AND pattern = $3',
- { response, message.chat.id, pattern }
- )
+ ctx.db.call('sp_update_trigger_response', { response, message.chat.id, pattern })
return api.send_message(message.chat.id, string.format(
'Trigger <code>%s</code> has been updated.',
tools.escape_html(pattern)
), 'html')
end
- ctx.db.insert('triggers', {
- chat_id = message.chat.id,
- pattern = pattern,
- response = response,
- created_by = message.from.id
- })
+ ctx.db.call('sp_insert_trigger', { message.chat.id, pattern, response, message.from.id })
- -- Invalidate trigger cache
+ -- invalidate trigger cache
local session = require('src.core.session')
session.invalidate_cached_list(message.chat.id, 'triggers')
api.send_message(message.chat.id, string.format(
'Trigger added: <code>%s</code> -> %s',
tools.escape_html(pattern),
tools.escape_html(response:sub(1, 100)) .. (#response > 100 and '...' or '')
), 'html')
end
--- Handle trigger matching on every new message
+-- handle trigger matching on every new message
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group or not message.text or message.text == '' then return end
- -- Don't trigger on commands
+ -- don't trigger on commands
if message.text:match('^[/!#]') then return end
- -- Cache triggers per chat (5-min TTL)
+ -- cache triggers per chat (5-min ttl)
local session = require('src.core.session')
local triggers = session.get_cached_list(message.chat.id, 'triggers', function()
- return ctx.db.execute(
- 'SELECT pattern, response, is_media, file_id FROM triggers WHERE chat_id = $1',
- { message.chat.id }
- )
+ return ctx.db.call('sp_get_triggers', { message.chat.id })
end, 300)
if not triggers or #triggers == 0 then return end
local text = message.text:lower()
for _, t in ipairs(triggers) do
local ok, matched = pcall(function()
return text:match(t.pattern:lower())
end)
if ok and matched then
if t.is_media and t.file_id then
- -- Send media response
+ -- send media response
api.send_document(message.chat.id, t.file_id, nil, nil, nil, message.message_id)
else
api.send_message(message.chat.id, t.response, nil, nil, nil, message.message_id)
end
return
end
end
end
return plugin
diff --git a/src/plugins/admin/administration.lua b/src/plugins/admin/administration.lua
index d537093..362e75d 100644
--- a/src/plugins/admin/administration.lua
+++ b/src/plugins/admin/administration.lua
@@ -1,190 +1,180 @@
--[[
mattata v2.0 - Administration Plugin
Main settings panel with inline keyboard for toggling settings.
]]
local plugin = {}
plugin.name = 'administration'
plugin.category = 'admin'
plugin.description = 'Main administration settings panel'
plugin.commands = { 'administration', 'settings' }
plugin.help = '/administration - Opens the administration settings panel. Alias: /settings'
plugin.group_only = true
plugin.admin_only = true
local json = require('dkjson')
--- Toggleable settings with display names and keys
+-- toggleable settings with display names and keys
local SETTINGS = {
{ key = 'antilink_enabled', name = 'Anti-Link', description = 'Delete Telegram invite links from non-admins' },
{ key = 'wordfilter_enabled', name = 'Word Filter', description = 'Filter messages matching patterns' },
{ key = 'captcha_enabled', name = 'Join Captcha', description = 'Require captcha for new members' },
{ key = 'antibot', name = 'Anti-Bot', description = 'Kick bots added by non-admins' },
{ key = 'delete_commands', name = 'Delete Commands', description = 'Auto-delete command messages' },
{ key = 'force_group_language', name = 'Force Group Language', description = 'Force all users to use group language' },
{ key = 'welcome_enabled', name = 'Welcome Message', description = 'Send welcome message for new members' },
{ key = 'log_admin_actions', name = 'Log Admin Actions', description = 'Log admin actions to log chat' },
{ key = 'anonymous_admin', name = 'Anonymous Admin', description = 'Hide admin names in action messages' },
{ key = 'lock_stickers', name = 'Lock Stickers', description = 'Prevent non-admins from sending stickers' },
{ key = 'lock_gifs', name = 'Lock GIFs', description = 'Prevent non-admins from sending GIFs' },
{ key = 'lock_forwards', name = 'Lock Forwards', description = 'Prevent non-admins from forwarding messages' }
}
local function is_setting_enabled(ctx, chat_id, key)
- local result = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = $2",
- { chat_id, key }
- )
+ local result = ctx.db.call('sp_get_chat_setting', { chat_id, key })
return result and #result > 0 and result[1].value == 'true'
end
local function build_keyboard(ctx, chat_id, page)
page = page or 1
local per_page = 6
local start_idx = (page - 1) * per_page + 1
local end_idx = math.min(start_idx + per_page - 1, #SETTINGS)
local total_pages = math.ceil(#SETTINGS / per_page)
local keyboard = { inline_keyboard = {} }
for i = start_idx, end_idx do
local s = SETTINGS[i]
local enabled = is_setting_enabled(ctx, chat_id, s.key)
local status_icon = enabled and '[ON]' or '[OFF]'
table.insert(keyboard.inline_keyboard, {
{
text = string.format('%s %s', s.name, status_icon),
callback_data = string.format('administration:toggle:%s:%d', s.key, page)
}
})
end
- -- Navigation row
+ -- navigation row
if total_pages > 1 then
local nav_row = {}
if page > 1 then
table.insert(nav_row, {
text = '<< Previous',
callback_data = 'administration:page:' .. (page - 1)
})
end
table.insert(nav_row, {
text = string.format('%d/%d', page, total_pages),
callback_data = 'administration:noop'
})
if page < total_pages then
table.insert(nav_row, {
text = 'Next >>',
callback_data = 'administration:page:' .. (page + 1)
})
end
table.insert(keyboard.inline_keyboard, nav_row)
end
- -- Close button
+ -- close button
table.insert(keyboard.inline_keyboard, {
{
text = 'Close',
callback_data = 'administration:close'
}
})
return keyboard
end
local function build_message(ctx, chat_id)
local tools = require('telegram-bot-lua.tools')
local chat_info = ''
local chat = ctx.api and ctx.api.get_chat(chat_id) or nil
if chat and chat.result then
chat_info = tools.escape_html(chat.result.title or 'this group')
else
chat_info = 'this group'
end
return string.format(
'<b>Administration settings for %s</b>\n\nTap a setting to toggle it on or off.',
chat_info
)
end
function plugin.on_message(api, message, ctx)
local text = build_message(ctx, message.chat.id)
local keyboard = build_keyboard(ctx, message.chat.id, 1)
api.send_message(message.chat.id, text, 'html', false, false, nil, json.encode(keyboard))
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local permissions = require('src.core.permissions')
local data = callback_query.data
if not data then return end
- -- Only admins can change settings
+ -- only admins can change settings
if not permissions.is_group_admin(api, message.chat.id, callback_query.from.id) then
return api.answer_callback_query(callback_query.id, 'Only admins can change settings.')
end
if data == 'noop' then
return api.answer_callback_query(callback_query.id)
end
if data == 'close' then
return api.delete_message(message.chat.id, message.message_id)
end
if data:match('^page:%d+$') then
local page = tonumber(data:match('^page:(%d+)$'))
local text = build_message(ctx, message.chat.id)
local keyboard = build_keyboard(ctx, message.chat.id, page)
api.edit_message_text(message.chat.id, message.message_id, text, 'html', false, json.encode(keyboard))
return api.answer_callback_query(callback_query.id)
end
if data:match('^toggle:') then
local key, page = data:match('^toggle:(%S+):(%d+)$')
if not key then
key = data:match('^toggle:(%S+)$')
page = 1
end
page = tonumber(page) or 1
- -- Toggle the setting
+ -- toggle the setting
local currently_enabled = is_setting_enabled(ctx, message.chat.id, key)
if currently_enabled then
- ctx.db.execute(
- "UPDATE chat_settings SET value = 'false' WHERE chat_id = $1 AND key = $2",
- { message.chat.id, key }
- )
+ ctx.db.call('sp_disable_chat_setting', { message.chat.id, key })
else
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = key,
- value = 'true'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, key, 'true' })
end
- -- Invalidate cache for the toggled setting
+ -- invalidate cache for the toggled setting
require('src.core.session').invalidate_setting(message.chat.id, key)
- -- Find the setting name for the callback response
+ -- find the setting name for the callback response
local setting_name = key
for _, s in ipairs(SETTINGS) do
if s.key == key then
setting_name = s.name
break
end
end
local new_state = not currently_enabled
- -- Rebuild keyboard with updated state
+ -- rebuild keyboard with updated state
local text = build_message(ctx, message.chat.id)
local keyboard = build_keyboard(ctx, message.chat.id, page)
api.edit_message_text(message.chat.id, message.message_id, text, 'html', false, json.encode(keyboard))
return api.answer_callback_query(callback_query.id, string.format(
'%s is now %s.', setting_name, new_state and 'enabled' or 'disabled'
))
end
end
return plugin
diff --git a/src/plugins/admin/allowedlinks.lua b/src/plugins/admin/allowedlinks.lua
index dcd2d07..1728ef0 100644
--- a/src/plugins/admin/allowedlinks.lua
+++ b/src/plugins/admin/allowedlinks.lua
@@ -1,35 +1,32 @@
--[[
mattata v2.0 - Allowed Links Plugin
]]
local plugin = {}
plugin.name = 'allowedlinks'
plugin.category = 'admin'
plugin.description = 'List allowed links in the group'
plugin.commands = { 'allowedlinks' }
plugin.help = '/allowedlinks - Lists all links that are allowed in this group when anti-link is enabled.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
- local result = ctx.db.execute(
- 'SELECT link FROM allowed_links WHERE chat_id = $1 ORDER BY link',
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_allowed_links', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No links are allowlisted. Use /allowlink <link> to add one.')
end
local output = '<b>Allowed links:</b>\n\n'
for i, row in ipairs(result) do
output = output .. string.format('%d. <code>%s</code>\n', i, tools.escape_html(row.link))
end
output = output .. string.format('\n<i>Total: %d link(s)</i>\nUse /allowlink <link> to add more.', #result)
api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/allowlink.lua b/src/plugins/admin/allowlink.lua
index 2ee0fdb..7e70f57 100644
--- a/src/plugins/admin/allowlink.lua
+++ b/src/plugins/admin/allowlink.lua
@@ -1,77 +1,65 @@
--[[
mattata v2.0 - Allow Link Plugin
]]
local plugin = {}
plugin.name = 'allowlink'
plugin.category = 'admin'
plugin.description = 'Add or remove a link from the allowed links list'
plugin.commands = { 'allowlink' }
plugin.help = '/allowlink <link|@username> - Adds a link to the allowed list. /allowlink remove <link> - Removes it.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
return api.send_message(message.chat.id, 'Usage:\n/allowlink <link|@username> - Allow a link\n/allowlink remove <link|@username> - Remove from allowed list')
end
local args = message.args
local is_remove = false
if args:lower():match('^remove%s+') or args:lower():match('^del%s+') then
is_remove = true
args = args:gsub('^%S+%s+', '')
end
- -- Normalise the link - extract the relevant part
+ -- normalise the link - extract the relevant part
local link = args:match('^%s*(.-)%s*$')
- -- Strip protocol and domain prefixes
+ -- strip protocol and domain prefixes
link = link:gsub('^https?://', '')
link = link:gsub('^[Tt]%.?[Mm][Ee]/', '')
link = link:gsub('^[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Mm][Ee]/', '')
link = link:gsub('^[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Dd][Oo][Gg]/', '')
link = link:gsub('^@', '')
if link == '' then
return api.send_message(message.chat.id, 'Please provide a valid link or username.')
end
if is_remove then
- ctx.db.execute(
- 'DELETE FROM allowed_links WHERE chat_id = $1 AND link = $2',
- { message.chat.id, link }
- )
- -- Also try with lowercase
- ctx.db.execute(
- 'DELETE FROM allowed_links WHERE chat_id = $1 AND link = $2',
- { message.chat.id, link:lower() }
- )
+ ctx.db.call('sp_delete_allowed_link', { message.chat.id, link })
+ -- also try with lowercase
+ ctx.db.call('sp_delete_allowed_link', { message.chat.id, link:lower() })
return api.send_message(message.chat.id, string.format(
'Link <code>%s</code> has been removed from the allowed list.',
tools.escape_html(link)
), 'html')
end
- -- Check if already allowed
- local existing = ctx.db.execute(
- 'SELECT 1 FROM allowed_links WHERE chat_id = $1 AND link = $2',
- { message.chat.id, link }
- )
+ -- check if already allowed
+ local existing = ctx.db.call('sp_check_allowed_link', { message.chat.id, link })
if existing and #existing > 0 then
return api.send_message(message.chat.id, 'That link is already allowed.')
end
- ctx.db.insert('allowed_links', {
- chat_id = message.chat.id,
- link = link
- })
+ ctx.db.call('sp_insert_allowed_link', { message.chat.id, link })
api.send_message(message.chat.id, string.format(
'Link <code>%s</code> has been added to the allowed list.',
tools.escape_html(link)
), 'html')
end
return plugin
diff --git a/src/plugins/admin/allowlist.lua b/src/plugins/admin/allowlist.lua
index 5cac143..95977f3 100644
--- a/src/plugins/admin/allowlist.lua
+++ b/src/plugins/admin/allowlist.lua
@@ -1,86 +1,76 @@
--[[
mattata v2.0 - Allowlist Plugin
]]
local plugin = {}
plugin.name = 'allowlist'
plugin.category = 'admin'
plugin.description = 'Manage the group allowlist'
plugin.commands = { 'allowlist' }
plugin.help = '/allowlist add <user> - Adds a user to the allowlist. /allowlist remove <user> - Removes a user. /allowlist - Lists allowlisted users.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
- -- List allowlisted users
- local result = ctx.db.execute(
- "SELECT user_id FROM chat_members WHERE chat_id = $1 AND role = 'allowlisted'",
- { message.chat.id }
- )
+ -- list allowlisted users
+ local result = ctx.db.call('sp_get_allowlisted_users', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No users are allowlisted.\nUsage: /allowlist add <user>')
end
local output = '<b>Allowlisted users:</b>\n\n'
for _, row in ipairs(result) do
local info = api.get_chat(row.user_id)
local name = info and info.result and tools.escape_html(info.result.first_name) or tostring(row.user_id)
output = output .. string.format('- <a href="tg://user?id=%s">%s</a> [%s]\n', row.user_id, name, row.user_id)
end
return api.send_message(message.chat.id, output, 'html')
end
local action, target = message.args:lower():match('^(%S+)%s+(.+)$')
if not action then
return api.send_message(message.chat.id, 'Usage: /allowlist <add|remove> <user>')
end
- -- Resolve target user
+ -- resolve target user
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
else
user_id = target:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'I couldn\'t find that user. Try replying to their message or providing a valid username/ID.')
end
if action == 'add' then
- ctx.db.upsert('chat_members', {
- chat_id = message.chat.id,
- user_id = user_id,
- role = 'allowlisted'
- }, { 'chat_id', 'user_id' }, { 'role' })
+ ctx.db.call('sp_set_member_role', { message.chat.id, user_id, 'allowlisted' })
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been added to the allowlist.',
user_id, target_name
), 'html')
elseif action == 'remove' or action == 'del' or action == 'delete' then
- ctx.db.execute(
- "UPDATE chat_members SET role = 'member' WHERE chat_id = $1 AND user_id = $2 AND role = 'allowlisted'",
- { message.chat.id, user_id }
- )
+ ctx.db.call('sp_remove_allowlisted', { message.chat.id, user_id })
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been removed from the allowlist.',
user_id, target_name
), 'html')
else
api.send_message(message.chat.id, 'Usage: /allowlist <add|remove> <user>')
end
end
return plugin
diff --git a/src/plugins/admin/antilink.lua b/src/plugins/admin/antilink.lua
index c7e2b18..fcaef8a 100644
--- a/src/plugins/admin/antilink.lua
+++ b/src/plugins/admin/antilink.lua
@@ -1,113 +1,96 @@
--[[
mattata v2.0 - Anti-Link Plugin
]]
local plugin = {}
plugin.name = 'antilink'
plugin.category = 'admin'
plugin.description = 'Toggle anti-link mode to delete Telegram invite links from non-admins'
plugin.commands = { 'antilink' }
plugin.help = '/antilink <on|off> - Toggle anti-link mode.'
plugin.group_only = true
plugin.admin_only = true
local INVITE_PATTERNS = {
'[Tt]%.?[Mm][Ee]/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/[%w_%-]+',
'[Tt]%.?[Mm][Ee]/[%+][%w_%-]+',
'[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Mm][Ee]/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/[%w_%-]+',
'[Tt][Ee][Ll][Ee][Gg][Rr][Aa][Mm]%.?[Dd][Oo][Gg]/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/[%w_%-]+',
'[Tt][Gg]://[Jj][Oo][Ii][Nn]%?[Ii][Nn][Vv][Ii][Tt][Ee]=[%w_%-]+'
}
function plugin.on_message(api, message, ctx)
if not message.args then
- local enabled = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'antilink_enabled'",
- { message.chat.id }
- )
+ local enabled = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'antilink_enabled' })
local status = (enabled and #enabled > 0 and enabled[1].value == 'true') and 'enabled' or 'disabled'
return api.send_message(message.chat.id, string.format(
'Anti-link is currently <b>%s</b>.\nUsage: /antilink <on|off>', status
), 'html')
end
local arg = message.args:lower()
if arg == 'on' or arg == 'enable' then
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'antilink_enabled',
- value = 'true'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'antilink_enabled', 'true' })
require('src.core.session').invalidate_setting(message.chat.id, 'antilink_enabled')
return api.send_message(message.chat.id, 'Anti-link has been enabled. Telegram invite links from non-admins will be deleted.')
elseif arg == 'off' or arg == 'disable' then
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'antilink_enabled',
- value = 'false'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'antilink_enabled', 'false' })
require('src.core.session').invalidate_setting(message.chat.id, 'antilink_enabled')
return api.send_message(message.chat.id, 'Anti-link has been disabled.')
else
return api.send_message(message.chat.id, 'Usage: /antilink <on|off>')
end
end
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group or not message.text or message.text == '' then return end
if ctx.is_admin or ctx.is_global_admin then return end
if not require('src.core.permissions').can_delete(api, message.chat.id) then return end
- -- Check if antilink is enabled (cached)
+ -- check if antilink is enabled (cached)
local session = require('src.core.session')
local enabled = session.get_cached_setting(message.chat.id, 'antilink_enabled', function()
- local result = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'antilink_enabled'",
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'antilink_enabled' })
if result and #result > 0 then return result[1].value end
return nil
end, 300)
if enabled ~= 'true' then
return
end
- -- Check if user is trusted
+ -- check if user is trusted
local permissions = require('src.core.permissions')
if permissions.is_trusted(ctx.db, message.chat.id, message.from.id) then
return
end
- -- Build full text including entity URLs
+ -- build full text including entity urls
local text = message.text
if message.entities then
for _, entity in ipairs(message.entities) do
if entity.type == 'text_link' and entity.url then
text = text .. ' ' .. entity.url
end
end
end
- -- Check for allowed links
+ -- check for allowed links
for _, pattern in ipairs(INVITE_PATTERNS) do
if text:match(pattern) then
- -- Check if link is allowed
+ -- check if link is allowed
local link = text:match(pattern)
- local allowed = ctx.db.execute(
- 'SELECT 1 FROM allowed_links WHERE chat_id = $1 AND link = $2',
- { message.chat.id, link }
- )
+ local allowed = ctx.db.call('sp_check_allowed_link', { message.chat.id, link })
if not allowed or #allowed == 0 then
api.delete_message(message.chat.id, message.message_id)
local tools = require('telegram-bot-lua.tools')
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a>, invite links are not allowed in this group.',
message.from.id, tools.escape_html(message.from.first_name)
), 'html')
return
end
end
end
end
return plugin
diff --git a/src/plugins/admin/antispam.lua b/src/plugins/admin/antispam.lua
index 4a4e260..2f2c7e7 100644
--- a/src/plugins/admin/antispam.lua
+++ b/src/plugins/admin/antispam.lua
@@ -1,81 +1,71 @@
--[[
mattata v2.0 - Antispam Plugin
]]
local plugin = {}
plugin.name = 'antispam'
plugin.category = 'admin'
plugin.description = 'Configure antispam settings'
plugin.commands = { 'antispam' }
plugin.help = '/antispam [text|sticker|photo|video|document|forward] <limit> - Set per-type message limits.'
plugin.group_only = true
plugin.admin_only = true
local VALID_TYPES = {
text = true,
sticker = true,
photo = true,
video = true,
document = true,
forward = true,
audio = true,
voice = true,
gif = true
}
function plugin.on_message(api, message, ctx)
if not message.args then
- -- Show current antispam settings
- local settings = ctx.db.execute(
- "SELECT key, value FROM chat_settings WHERE chat_id = $1 AND key LIKE 'antispam_%'",
- { message.chat.id }
- )
+ -- show current antispam settings
+ local settings = ctx.db.call('sp_get_chat_settings_like', { message.chat.id, 'antispam_%' })
local output = '<b>Antispam settings:</b>\n\n'
if settings and #settings > 0 then
for _, row in ipairs(settings) do
local msg_type = row.key:gsub('antispam_', '')
output = output .. string.format('- %s: %s message(s) per 5 seconds\n', msg_type, row.value)
end
else
output = output .. 'No custom limits set. Default limits apply.\n'
end
output = output .. '\nUsage: <code>/antispam &lt;type&gt; &lt;limit&gt;</code>\nTypes: text, sticker, photo, video, document, forward, audio, voice, gif\n'
output = output .. '<code>/antispam &lt;type&gt; off</code> - Remove limit'
return api.send_message(message.chat.id, output, 'html')
end
local msg_type, limit = message.args:lower():match('^(%S+)%s+(.+)$')
if not msg_type then
return api.send_message(message.chat.id, 'Usage: /antispam <type> <limit|off>')
end
if not VALID_TYPES[msg_type] then
return api.send_message(message.chat.id, 'Invalid type. Valid types: text, sticker, photo, video, document, forward, audio, voice, gif')
end
local setting_key = 'antispam_' .. msg_type
if limit == 'off' or limit == 'disable' or limit == '0' then
- ctx.db.execute(
- "DELETE FROM chat_settings WHERE chat_id = $1 AND key = $2",
- { message.chat.id, setting_key }
- )
+ ctx.db.call('sp_delete_chat_setting', { message.chat.id, setting_key })
return api.send_message(message.chat.id, string.format('Antispam limit for <b>%s</b> has been removed.', msg_type), 'html')
end
limit = tonumber(limit)
if not limit or limit < 1 or limit > 100 then
return api.send_message(message.chat.id, 'Limit must be a number between 1 and 100.')
end
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = setting_key,
- value = tostring(limit)
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, setting_key, tostring(limit) })
api.send_message(message.chat.id, string.format(
'Antispam limit for <b>%s</b> set to <b>%d</b> message(s) per 5 seconds.',
msg_type, limit
), 'html')
end
return plugin
diff --git a/src/plugins/admin/ban.lua b/src/plugins/admin/ban.lua
index 6cb1083..f2ad125 100644
--- a/src/plugins/admin/ban.lua
+++ b/src/plugins/admin/ban.lua
@@ -1,100 +1,89 @@
--[[
mattata v2.0 - Ban Plugin
]]
local plugin = {}
plugin.name = 'ban'
plugin.category = 'admin'
plugin.description = 'Ban users from a group'
plugin.commands = { 'ban', 'b' }
plugin.help = '/ban [user] [reason] - Bans a user from the current chat.'
plugin.group_only = true
plugin.admin_only = true
local function resolve_target(api, message, ctx)
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then return nil, nil end
- -- Strip 'for' prefix from reason
+ -- strip 'for' prefix from reason
if reason and reason:lower():match('^for ') then
reason = reason:sub(5)
end
- -- Resolve username to ID
+ -- resolve username to id
if tonumber(user_id) == nil then
user_id = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. user_id:lower())
end
return tonumber(user_id), reason
end
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, reason = resolve_target(api, message, ctx)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to ban, either by replying to their message or providing a username/ID.')
end
if user_id == api.info.id then return end
- -- Check target isn't an admin
+ -- check target isn't an admin
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t ban an admin or moderator.')
end
- -- Attempt ban
+ -- attempt ban
local success = api.ban_chat_member(message.chat.id, user_id)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to ban users. Please make sure I\'m an admin with ban rights.')
end
- -- Log to database
+ -- log to database
pcall(function()
- ctx.db.insert('bans', {
- chat_id = message.chat.id,
- user_id = user_id,
- banned_by = message.from.id,
- reason = reason
- })
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- target_id = user_id,
- action = 'ban',
- reason = reason
- })
+ ctx.db.call('sp_insert_ban', { message.chat.id, user_id, message.from.id, reason })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'ban', reason })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and ('\nReason: ' .. tools.escape_html(reason)) or ''
local output = string.format(
'<a href="tg://user?id=%d">%s</a> has banned <a href="tg://user?id=%d">%s</a>.%s',
message.from.id, admin_name, user_id, target_name, reason_text
)
api.send_message(message.chat.id, output, 'html')
- -- Clean up messages
+ -- clean up messages
if message.reply then
pcall(function() api.delete_message(message.chat.id, message.reply.message_id) end)
end
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
end
return plugin
diff --git a/src/plugins/admin/blocklist.lua b/src/plugins/admin/blocklist.lua
index 39594ae..450e3b3 100644
--- a/src/plugins/admin/blocklist.lua
+++ b/src/plugins/admin/blocklist.lua
@@ -1,118 +1,108 @@
--[[
mattata v2.0 - Blocklist Plugin
]]
local plugin = {}
plugin.name = 'blocklist'
plugin.category = 'admin'
plugin.description = 'Manage the group blocklist'
plugin.commands = { 'blocklist', 'block', 'unblock' }
plugin.help = '/blocklist - List blocked users. /block <user> [reason] - Block a user. /unblock <user> - Unblock a user.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
-- /blocklist with no args: list blocked users
if message.command == 'blocklist' and not message.args then
- local result = ctx.db.execute(
- 'SELECT user_id, reason, created_at FROM group_blocklist WHERE chat_id = $1 ORDER BY created_at DESC',
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_blocklist', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No users are blocklisted in this group.')
end
local output = '<b>Blocklisted users:</b>\n\n'
for _, row in ipairs(result) do
local info = api.get_chat(row.user_id)
local name = info and info.result and tools.escape_html(info.result.first_name) or tostring(row.user_id)
local reason_text = row.reason and (' - ' .. tools.escape_html(row.reason)) or ''
output = output .. string.format('- <a href="tg://user?id=%s">%s</a> [%s]%s\n', row.user_id, name, row.user_id, reason_text)
end
return api.send_message(message.chat.id, output, 'html')
end
-- /block or /blocklist add
if message.command == 'block' or (message.command == 'blocklist' and message.args and message.args:match('^add')) then
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.command == 'blocklist' and message.args:gsub('^add%s*', '') or message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input:match('^(%S+)')
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to block.')
end
if tonumber(user_id) == nil then
user_id = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. user_id:lower())
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'I couldn\'t find that user.')
end
if user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'You can\'t blocklist an admin.')
end
- ctx.db.upsert('group_blocklist', {
- chat_id = message.chat.id,
- user_id = user_id,
- reason = reason
- }, { 'chat_id', 'user_id' }, { 'reason' })
+ ctx.db.call('sp_upsert_blocklist_entry', { message.chat.id, user_id, reason })
- -- Also set Redis key for fast lookup
+ -- also set redis key for fast lookup
ctx.redis.set('group_blocklist:' .. message.chat.id .. ':' .. user_id, '1')
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been added to the blocklist.',
user_id, target_name
), 'html')
end
-- /unblock or /blocklist remove
if message.command == 'unblock' or (message.command == 'blocklist' and message.args and message.args:match('^remove')) then
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
local input = message.command == 'blocklist' and message.args:gsub('^remove%s*', '') or message.args
user_id = input:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to unblock.')
end
- ctx.db.execute(
- 'DELETE FROM group_blocklist WHERE chat_id = $1 AND user_id = $2',
- { message.chat.id, user_id }
- )
+ ctx.db.call('sp_delete_blocklist_entry', { message.chat.id, user_id })
ctx.redis.del('group_blocklist:' .. message.chat.id .. ':' .. user_id)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been removed from the blocklist.',
user_id, target_name
), 'html')
end
api.send_message(message.chat.id, 'Usage: /block <user> [reason] | /unblock <user> | /blocklist')
end
return plugin
diff --git a/src/plugins/admin/demote.lua b/src/plugins/admin/demote.lua
index dfbfeec..a3bc5a4 100644
--- a/src/plugins/admin/demote.lua
+++ b/src/plugins/admin/demote.lua
@@ -1,59 +1,51 @@
--[[
mattata v2.0 - Demote Plugin
]]
local plugin = {}
plugin.name = 'demote'
plugin.category = 'admin'
plugin.description = 'Remove moderator status from a user'
plugin.commands = { 'demote' }
plugin.help = '/demote [user] - Removes moderator status from a user.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to demote, either by replying to their message or providing a username/ID.')
end
if not permissions.is_group_mod(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is not a moderator.')
end
- ctx.db.execute(
- "UPDATE chat_members SET role = 'member' WHERE chat_id = $1 AND user_id = $2",
- { message.chat.id, user_id }
- )
+ ctx.db.call('sp_reset_member_role', { message.chat.id, user_id })
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- target_id = user_id,
- action = 'demote'
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'demote', nil })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has demoted <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
), 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/delfed.lua b/src/plugins/admin/federation/delfed.lua
index 3e68ddf..4b7bf46 100644
--- a/src/plugins/admin/federation/delfed.lua
+++ b/src/plugins/admin/federation/delfed.lua
@@ -1,133 +1,124 @@
--[[
mattata v2.0 - Federation: delfed
Deletes a federation. Only the federation owner can delete it.
Requires confirmation via inline callback.
]]
local tools = require('telegram-bot-lua.tools')
local json = require('dkjson')
local plugin = {}
plugin.name = 'delfed'
plugin.category = 'admin'
plugin.description = 'Delete a federation you own.'
plugin.commands = { 'delfed' }
plugin.help = '/delfed <federation_id> - Delete a federation you own.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local fed_id = message.args
if not fed_id or fed_id == '' then
return api.send_message(
message.chat.id,
'Please specify the federation ID.\nUsage: <code>/delfed &lt;federation_id&gt;</code>',
'html'
)
end
fed_id = fed_id:match('^(%S+)')
- local fed = ctx.db.execute(
- 'SELECT id, name, owner_id FROM federations WHERE id = $1',
- { fed_id }
- )
+ local fed = ctx.db.call('sp_get_federation', { fed_id })
if not fed or #fed == 0 then
return api.send_message(
message.chat.id,
'Federation not found. Please check the ID and try again.',
'html'
)
end
fed = fed[1]
if fed.owner_id ~= message.from.id then
return api.send_message(
message.chat.id,
'Only the federation owner can delete it.',
'html'
)
end
local callback_data_yes = json.encode({ plugin = 'delfed', action = 'confirm', fed_id = fed.id })
local callback_data_no = json.encode({ plugin = 'delfed', action = 'cancel' })
local keyboard = {
inline_keyboard = { {
{ text = 'Yes, delete it', callback_data = callback_data_yes },
{ text = 'No, cancel', callback_data = callback_data_no }
} }
}
return api.send_message(
message.chat.id,
string.format(
'Are you sure you want to delete the federation <b>%s</b>?\n\nThis will remove all bans, chats, and admins associated with it. This action cannot be undone.',
tools.escape_html(fed.name)
),
'html',
nil, nil, nil, nil,
json.encode(keyboard)
)
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local data = json.decode(callback_query.data)
if not data or data.plugin ~= 'delfed' then
return
end
if callback_query.from.id ~= message.reply_to_message_from_id and callback_query.from.id ~= (message.from and message.from.id) then
return api.answer_callback_query(callback_query.id, 'This button is not for you.')
end
if data.action == 'cancel' then
api.answer_callback_query(callback_query.id, 'Deletion cancelled.')
return api.edit_message_text(
message.chat.id,
message.message_id,
'Federation deletion cancelled.',
'html'
)
end
if data.action == 'confirm' then
- local fed = ctx.db.execute(
- 'SELECT name, owner_id FROM federations WHERE id = $1',
- { data.fed_id }
- )
+ local fed = ctx.db.call('sp_get_federation_owner', { data.fed_id })
if not fed or #fed == 0 then
api.answer_callback_query(callback_query.id, 'Federation no longer exists.')
return api.edit_message_text(
message.chat.id,
message.message_id,
'This federation no longer exists.',
'html'
)
end
if fed[1].owner_id ~= callback_query.from.id then
return api.answer_callback_query(callback_query.id, 'Only the federation owner can delete it.')
end
- ctx.db.execute(
- 'DELETE FROM federations WHERE id = $1',
- { data.fed_id }
- )
+ ctx.db.call('sp_delete_federation', { data.fed_id })
api.answer_callback_query(callback_query.id, 'Federation deleted.')
return api.edit_message_text(
message.chat.id,
message.message_id,
string.format(
'Federation <b>%s</b> has been deleted.',
tools.escape_html(fed[1].name)
),
'html'
)
end
end
return plugin
diff --git a/src/plugins/admin/federation/fadmins.lua b/src/plugins/admin/federation/fadmins.lua
index 510d762..126a494 100644
--- a/src/plugins/admin/federation/fadmins.lua
+++ b/src/plugins/admin/federation/fadmins.lua
@@ -1,61 +1,55 @@
--[[
mattata v2.0 - Federation: fadmins
Lists all admins of the federation the current chat belongs to.
Shows the owner separately from promoted admins.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fadmins'
plugin.category = 'admin'
plugin.description = 'List federation admins.'
plugin.commands = { 'fadmins' }
plugin.help = '/fadmins - List all admins of this federation.'
plugin.group_only = true
plugin.admin_only = false
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
'html'
)
end
local output = string.format(
'<b>Federation Admins</b>\nFederation: <b>%s</b>\n\n<b>Owner:</b>\n<code>%s</code>',
tools.escape_html(fed.name),
fed.owner_id
)
- local admins = ctx.db.execute(
- 'SELECT fa.user_id, fa.promoted_at FROM federation_admins fa WHERE fa.federation_id = $1 ORDER BY fa.promoted_at ASC',
- { fed.id }
- )
+ local admins = ctx.db.call('sp_get_federation_admins', { fed.id })
if admins and #admins > 0 then
output = output .. string.format('\n\n<b>Admins (%d):</b>', #admins)
for i, admin in ipairs(admins) do
output = output .. string.format('\n%d. <code>%s</code>', i, admin.user_id)
end
else
output = output .. '\n\nNo promoted admins.'
end
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/fallowlist.lua b/src/plugins/admin/federation/fallowlist.lua
index 5e27a01..a456051 100644
--- a/src/plugins/admin/federation/fallowlist.lua
+++ b/src/plugins/admin/federation/fallowlist.lua
@@ -1,126 +1,106 @@
--[[
mattata v2.0 - Federation: fallowlist
Manages the federation allowlist. Allowlisted users are exempt from
federation bans. Only the federation owner or admins can manage it.
Toggles the user on/off the allowlist.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fallowlist'
plugin.category = 'admin'
plugin.description = 'Toggle a user on the federation allowlist.'
plugin.commands = { 'fallowlist' }
plugin.help = '/fallowlist [user] - Toggle a user on/off the federation allowlist.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
local function is_fed_admin(db, fed_id, user_id)
- local result = db.execute(
- 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
- { fed_id, user_id }
- )
+ local result = db.call('sp_check_federation_admin', { fed_id, user_id })
return result and #result > 0
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
'html'
)
end
local from_id = message.from.id
if fed.owner_id ~= from_id and not is_fed_admin(ctx.db, fed.id, from_id) then
return api.send_message(
message.chat.id,
'Only the federation owner or a federation admin can manage the allowlist.',
'html'
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to toggle on the allowlist by replying to their message or providing a user ID/username.\nUsage: <code>/fallowlist [user]</code>',
'html'
)
end
- -- Check if already allowlisted
- local existing = ctx.db.execute(
- 'SELECT 1 FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ local existing = ctx.db.call('sp_check_federation_allowlist', { fed.id, target_id })
if existing and #existing > 0 then
- -- Remove from allowlist
- ctx.db.execute(
- 'DELETE FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
- -- Invalidate Redis cache
+ ctx.db.call('sp_delete_federation_allowlist', { fed.id, target_id })
ctx.redis.del(string.format('fallowlist:%s:%s', fed.id, target_id))
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been removed from the federation allowlist.',
tools.escape_html(target_name)
),
'html'
)
else
- -- Add to allowlist
- ctx.db.execute(
- 'INSERT INTO federation_allowlist (federation_id, user_id) VALUES ($1, $2)',
- { fed.id, target_id }
- )
- -- Invalidate Redis cache
+ ctx.db.call('sp_insert_federation_allowlist', { fed.id, target_id })
ctx.redis.del(string.format('fallowlist:%s:%s', fed.id, target_id))
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been added to the federation allowlist.',
tools.escape_html(target_name)
),
'html'
)
end
end
return plugin
diff --git a/src/plugins/admin/federation/fban.lua b/src/plugins/admin/federation/fban.lua
index 721347c..19db91b 100644
--- a/src/plugins/admin/federation/fban.lua
+++ b/src/plugins/admin/federation/fban.lua
@@ -1,184 +1,157 @@
--[[
mattata v2.0 - Federation: fban
Bans a user across all chats in the federation.
Only the federation owner or a federation admin can issue an fban.
Allowlisted users are exempt.
]]
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local plugin = {}
plugin.name = 'fban'
plugin.category = 'admin'
plugin.description = 'Ban a user across the federation.'
plugin.commands = { 'fban' }
plugin.help = '/fban [user] [reason] - Ban a user in all chats belonging to this federation.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
local function is_fed_admin(db, fed_id, user_id)
- local result = db.execute(
- 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
- { fed_id, user_id }
- )
+ local result = db.call('sp_check_federation_admin', { fed_id, user_id })
return result and #result > 0
end
local function is_allowlisted(db, fed_id, user_id)
- local result = db.execute(
- 'SELECT 1 FROM federation_allowlist WHERE federation_id = $1 AND user_id = $2',
- { fed_id, user_id }
- )
+ local result = db.call('sp_check_federation_allowlist', { fed_id, user_id })
return result and #result > 0
end
function plugin.on_message(api, message, ctx)
if message.chat.type ~= 'private' and not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to enforce federation bans.')
end
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
'html'
)
end
local from_id = message.from.id
if fed.owner_id ~= from_id and not is_fed_admin(ctx.db, fed.id, from_id) then
return api.send_message(
message.chat.id,
'Only the federation owner or a federation admin can use this command.',
'html'
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to ban by replying to their message or providing a user ID/username.\nUsage: <code>/fban [user] [reason]</code>',
'html'
)
end
- -- Don't allow banning the federation owner
+ -- don't allow banning the federation owner
if target_id == fed.owner_id then
return api.send_message(
message.chat.id,
'You cannot federation-ban the federation owner.',
'html'
)
end
- -- Check allowlist
if is_allowlisted(ctx.db, fed.id, target_id) then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> is on the federation allowlist and cannot be banned.',
tools.escape_html(target_name)
),
'html'
)
end
- -- Extract reason (everything after the user identifier)
local reason
if message.reply and message.reply.from and message.args and message.args ~= '' then
reason = message.args
elseif message.args and message.args ~= '' then
reason = message.args:match('^%S+%s+(.*)')
end
- -- Check if already banned
- local existing_ban = ctx.db.execute(
- 'SELECT 1 FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ local existing_ban = ctx.db.call('sp_check_federation_ban_exists', { fed.id, target_id })
if existing_ban and #existing_ban > 0 then
- -- Update reason if provided
if reason then
- ctx.db.execute(
- 'UPDATE federation_bans SET reason = $1, banned_by = $2, banned_at = NOW() WHERE federation_id = $3 AND user_id = $4',
- { reason, from_id, fed.id, target_id }
- )
+ ctx.db.call('sp_update_federation_ban', { reason, from_id, fed.id, target_id })
end
else
- ctx.db.execute(
- 'INSERT INTO federation_bans (federation_id, user_id, reason, banned_by) VALUES ($1, $2, $3, $4)',
- { fed.id, target_id, reason, from_id }
- )
+ ctx.db.call('sp_insert_federation_ban', { fed.id, target_id, reason, from_id })
end
- -- Invalidate Redis cache
ctx.redis.del(string.format('fban:%s:%s', fed.id, target_id))
- -- Get all chats in the federation and ban the user
- local chats = ctx.db.execute(
- 'SELECT chat_id FROM federation_chats WHERE federation_id = $1',
- { fed.id }
- )
+ local chats = ctx.db.call('sp_get_federation_chats', { fed.id })
local success_count = 0
local fail_count = 0
if chats then
for _, chat in ipairs(chats) do
local ok = api.ban_chat_member(chat.chat_id, target_id)
if ok then
success_count = success_count + 1
else
fail_count = fail_count + 1
end
end
end
local output = string.format(
'<b>Federation Ban</b>\nFederation: <b>%s</b>\nUser: <b>%s</b> (<code>%s</code>)\nBanned by: %s',
tools.escape_html(fed.name),
tools.escape_html(target_name),
target_id,
tools.escape_html(message.from.first_name)
)
if reason then
output = output .. string.format('\nReason: %s', tools.escape_html(reason))
end
output = output .. string.format('\nBanned in %d/%d chats.', success_count, success_count + fail_count)
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/fbaninfo.lua b/src/plugins/admin/federation/fbaninfo.lua
index f53226b..acde86b 100644
--- a/src/plugins/admin/federation/fbaninfo.lua
+++ b/src/plugins/admin/federation/fbaninfo.lua
@@ -1,107 +1,92 @@
--[[
mattata v2.0 - Federation: fbaninfo
Checks if a user is banned in any federation the current chat belongs to.
Shows the ban reason, who banned them, and when.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fbaninfo'
plugin.category = 'admin'
plugin.description = 'Check federation ban info for a user.'
plugin.commands = { 'fbaninfo' }
plugin.help = '/fbaninfo [user] - Check if a user is banned in this federation.'
plugin.group_only = false
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
function plugin.on_message(api, message, ctx)
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
- -- Default to the sender if no user specified
target_id = message.from.id
target_name = message.from.first_name
end
- -- Find all federations this chat belongs to (or all federations if in private)
local bans
if ctx.is_group then
- bans = ctx.db.execute(
- [[SELECT fb.reason, fb.banned_by, fb.banned_at, f.name, f.id
- FROM federation_bans fb
- JOIN federations f ON fb.federation_id = f.id
- JOIN federation_chats fc ON f.id = fc.federation_id
- WHERE fb.user_id = $1 AND fc.chat_id = $2]],
- { target_id, message.chat.id }
- )
+ bans = ctx.db.call('sp_get_fban_info_group', { target_id, message.chat.id })
else
- bans = ctx.db.execute(
- [[SELECT fb.reason, fb.banned_by, fb.banned_at, f.name, f.id
- FROM federation_bans fb
- JOIN federations f ON fb.federation_id = f.id
- WHERE fb.user_id = $1]],
- { target_id }
- )
+ bans = ctx.db.call('sp_get_fban_info_all', { target_id })
end
if not bans or #bans == 0 then
local scope = ctx.is_group and 'this federation' or 'any federation'
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> (<code>%s</code>) is not banned in %s.',
tools.escape_html(target_name),
target_id,
scope
),
'html'
)
end
local output = string.format(
'<b>Federation Ban Info</b>\nUser: <b>%s</b> (<code>%s</code>)\n',
tools.escape_html(target_name),
target_id
)
for i, ban in ipairs(bans) do
output = output .. string.format(
'\n<b>%d.</b> Federation: <b>%s</b>\n ID: <code>%s</code>',
i,
tools.escape_html(ban.name),
tools.escape_html(ban.id)
)
if ban.reason then
output = output .. string.format('\n Reason: %s', tools.escape_html(ban.reason))
end
if ban.banned_by then
output = output .. string.format('\n Banned by: <code>%s</code>', ban.banned_by)
end
if ban.banned_at then
output = output .. string.format('\n Date: %s', tools.escape_html(tostring(ban.banned_at)))
end
end
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/fdemote.lua b/src/plugins/admin/federation/fdemote.lua
index 54ee84c..b026455 100644
--- a/src/plugins/admin/federation/fdemote.lua
+++ b/src/plugins/admin/federation/fdemote.lua
@@ -1,104 +1,94 @@
--[[
mattata v2.0 - Federation: fdemote
Demotes a federation admin. Only the federation owner can demote.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fdemote'
plugin.category = 'admin'
plugin.description = 'Demote a federation admin.'
plugin.commands = { 'fdemote' }
plugin.help = '/fdemote [user] - Demote a federation admin.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
'html'
)
end
if fed.owner_id ~= message.from.id then
return api.send_message(
message.chat.id,
'Only the federation owner can demote admins.',
'html'
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to demote by replying to their message or providing a user ID/username.\nUsage: <code>/fdemote [user]</code>',
'html'
)
end
- -- Check if actually an admin
- local existing = ctx.db.execute(
- 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ local existing = ctx.db.call('sp_check_federation_admin', { fed.id, target_id })
if not existing or #existing == 0 then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> is not a federation admin.',
tools.escape_html(target_name)
),
'html'
)
end
- ctx.db.execute(
- 'DELETE FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ ctx.db.call('sp_delete_federation_admin', { fed.id, target_id })
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been demoted from federation admin in <b>%s</b>.',
tools.escape_html(target_name),
tools.escape_html(fed.name)
),
'html'
)
end
return plugin
diff --git a/src/plugins/admin/federation/feds.lua b/src/plugins/admin/federation/feds.lua
index 03173db..3db3b63 100644
--- a/src/plugins/admin/federation/feds.lua
+++ b/src/plugins/admin/federation/feds.lua
@@ -1,105 +1,83 @@
--[[
mattata v2.0 - Federation: feds / fedinfo
Shows info about a specific federation by ID, or the federation
the current chat belongs to.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'feds'
plugin.category = 'admin'
plugin.description = 'Show federation info.'
plugin.commands = { 'feds', 'fedinfo' }
plugin.help = '/feds [federation_id] - Show info about a federation.\n/fedinfo [federation_id] - Alias for /feds.'
plugin.group_only = false
plugin.admin_only = false
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed_id = message.args and message.args:match('^(%S+)')
local fed
if fed_id and fed_id ~= '' then
- local result = ctx.db.execute(
- 'SELECT id, name, owner_id, created_at FROM federations WHERE id = $1',
- { fed_id }
- )
+ local result = ctx.db.call('sp_get_federation', { fed_id })
if not result or #result == 0 then
return api.send_message(
message.chat.id,
'Federation not found. Please check the ID and try again.',
'html'
)
end
fed = result[1]
elseif ctx.is_group then
fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation. Provide a federation ID to look up.\nUsage: <code>/feds &lt;federation_id&gt;</code>',
'html'
)
end
- -- Fetch created_at since get_chat_federation doesn't include it
- local full = ctx.db.execute(
- 'SELECT created_at FROM federations WHERE id = $1',
- { fed.id }
- )
+ local full = ctx.db.call('sp_get_federation', { fed.id })
if full and #full > 0 then
fed.created_at = full[1].created_at
end
else
return api.send_message(
message.chat.id,
'Please specify a federation ID.\nUsage: <code>/feds &lt;federation_id&gt;</code>',
'html'
)
end
- -- Get counts
- local admin_count = ctx.db.execute(
- 'SELECT COUNT(*) AS count FROM federation_admins WHERE federation_id = $1',
- { fed.id }
- )
- local chat_count = ctx.db.execute(
- 'SELECT COUNT(*) AS count FROM federation_chats WHERE federation_id = $1',
- { fed.id }
- )
- local ban_count = ctx.db.execute(
- 'SELECT COUNT(*) AS count FROM federation_bans WHERE federation_id = $1',
- { fed.id }
- )
-
- local admins = (admin_count and admin_count[1]) and tonumber(admin_count[1].count) or 0
- local chats = (chat_count and chat_count[1]) and tonumber(chat_count[1].count) or 0
- local bans = (ban_count and ban_count[1]) and tonumber(ban_count[1].count) or 0
+ local counts = ctx.db.call('sp_get_federation_counts', { fed.id })
+ local counts_row = (counts and counts[1]) or {}
+ local admins = tonumber(counts_row.admin_count) or 0
+ local chats = tonumber(counts_row.chat_count) or 0
+ local bans = tonumber(counts_row.ban_count) or 0
local output = string.format(
'<b>Federation Info</b>\n\nName: <b>%s</b>\nID: <code>%s</code>\nOwner: <code>%s</code>\nAdmins: %d\nChats: %d\nBans: %d',
tools.escape_html(fed.name),
tools.escape_html(fed.id),
fed.owner_id,
admins,
chats,
bans
)
if fed.created_at then
output = output .. string.format('\nCreated: %s', tools.escape_html(tostring(fed.created_at)))
end
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/fpromote.lua b/src/plugins/admin/federation/fpromote.lua
index 4989785..e6651de 100644
--- a/src/plugins/admin/federation/fpromote.lua
+++ b/src/plugins/admin/federation/fpromote.lua
@@ -1,112 +1,102 @@
--[[
mattata v2.0 - Federation: fpromote
Promotes a user to federation admin. Only the federation owner can promote.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'fpromote'
plugin.category = 'admin'
plugin.description = 'Promote a user to federation admin.'
plugin.commands = { 'fpromote' }
plugin.help = '/fpromote [user] - Promote a user to federation admin.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
function plugin.on_message(api, message, ctx)
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
'html'
)
end
if fed.owner_id ~= message.from.id then
return api.send_message(
message.chat.id,
'Only the federation owner can promote admins.',
'html'
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to promote by replying to their message or providing a user ID/username.\nUsage: <code>/fpromote [user]</code>',
'html'
)
end
if target_id == fed.owner_id then
return api.send_message(
message.chat.id,
'The federation owner cannot be promoted as an admin.',
'html'
)
end
- -- Check if already an admin
- local existing = ctx.db.execute(
- 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ local existing = ctx.db.call('sp_check_federation_admin', { fed.id, target_id })
if existing and #existing > 0 then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> is already a federation admin.',
tools.escape_html(target_name)
),
'html'
)
end
- ctx.db.execute(
- 'INSERT INTO federation_admins (federation_id, user_id, promoted_by) VALUES ($1, $2, $3)',
- { fed.id, target_id, message.from.id }
- )
+ ctx.db.call('sp_insert_federation_admin', { fed.id, target_id, message.from.id })
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> has been promoted to federation admin in <b>%s</b>.',
tools.escape_html(target_name),
tools.escape_html(fed.name)
),
'html'
)
end
return plugin
diff --git a/src/plugins/admin/federation/joinfed.lua b/src/plugins/admin/federation/joinfed.lua
index 6ddaf18..25245ec 100644
--- a/src/plugins/admin/federation/joinfed.lua
+++ b/src/plugins/admin/federation/joinfed.lua
@@ -1,84 +1,73 @@
--[[
mattata v2.0 - Federation: joinfed
Joins the current chat to a federation. Requires group admin.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'joinfed'
plugin.category = 'admin'
plugin.description = 'Join this chat to a federation.'
plugin.commands = { 'joinfed' }
plugin.help = '/joinfed <federation_id> - Join this chat to the specified federation.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local fed_id = message.args
if not fed_id or fed_id == '' then
return api.send_message(
message.chat.id,
'Please specify the federation ID.\nUsage: <code>/joinfed &lt;federation_id&gt;</code>',
'html'
)
end
fed_id = fed_id:match('^(%S+)')
local chat_id = message.chat.id
- -- Check if the chat is already in a federation
- local existing = ctx.db.execute(
- 'SELECT f.id, f.name FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local existing = ctx.db.call('sp_get_chat_federation_joined', { chat_id })
if existing and #existing > 0 then
return api.send_message(
chat_id,
string.format(
'This chat is already part of the federation <b>%s</b>.\nUse /leavefed to leave it first.',
tools.escape_html(existing[1].name)
),
'html'
)
end
- -- Check if the federation exists
- local fed = ctx.db.execute(
- 'SELECT id, name FROM federations WHERE id = $1',
- { fed_id }
- )
+ local fed = ctx.db.call('sp_get_federation_basic', { fed_id })
if not fed or #fed == 0 then
return api.send_message(
chat_id,
'Federation not found. Please check the ID and try again.',
'html'
)
end
fed = fed[1]
- local result = ctx.db.execute(
- 'INSERT INTO federation_chats (federation_id, chat_id, joined_by) VALUES ($1, $2, $3)',
- { fed.id, chat_id, message.from.id }
- )
+ local result = ctx.db.call('sp_join_federation', { fed.id, chat_id, message.from.id })
if not result then
return api.send_message(
chat_id,
'Failed to join the federation. Please try again later.',
'html'
)
end
return api.send_message(
chat_id,
string.format(
'This chat has joined the federation <b>%s</b>.',
tools.escape_html(fed.name)
),
'html'
)
end
return plugin
diff --git a/src/plugins/admin/federation/leavefed.lua b/src/plugins/admin/federation/leavefed.lua
index 9d97040..82882c2 100644
--- a/src/plugins/admin/federation/leavefed.lua
+++ b/src/plugins/admin/federation/leavefed.lua
@@ -1,51 +1,44 @@
--[[
mattata v2.0 - Federation: leavefed
Removes the current chat from its federation. Requires group admin.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'leavefed'
plugin.category = 'admin'
plugin.description = 'Remove this chat from its federation.'
plugin.commands = { 'leavefed' }
plugin.help = '/leavefed - Remove this chat from its current federation.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local chat_id = message.chat.id
- -- Check if the chat is in a federation
- local existing = ctx.db.execute(
- 'SELECT f.id, f.name FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local existing = ctx.db.call('sp_get_chat_federation_joined', { chat_id })
if not existing or #existing == 0 then
return api.send_message(
chat_id,
'This chat is not part of any federation.',
'html'
)
end
local fed = existing[1]
- ctx.db.execute(
- 'DELETE FROM federation_chats WHERE federation_id = $1 AND chat_id = $2',
- { fed.id, chat_id }
- )
+ ctx.db.call('sp_leave_federation', { fed.id, chat_id })
return api.send_message(
chat_id,
string.format(
'This chat has left the federation <b>%s</b>.',
tools.escape_html(fed.name)
),
'html'
)
end
return plugin
diff --git a/src/plugins/admin/federation/myfeds.lua b/src/plugins/admin/federation/myfeds.lua
index 1b2c316..7fb5c2d 100644
--- a/src/plugins/admin/federation/myfeds.lua
+++ b/src/plugins/admin/federation/myfeds.lua
@@ -1,89 +1,70 @@
--[[
mattata v2.0 - Federation: myfeds
Lists all federations the user owns or is admin of.
Shows federation name, ID, chat count, and ban count.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'myfeds'
plugin.category = 'admin'
plugin.description = 'List your federations.'
plugin.commands = { 'myfeds' }
plugin.help = '/myfeds - List all federations you own or are an admin of.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local user_id = message.from.id
- -- Federations owned by the user
- local owned = ctx.db.execute(
- [[SELECT f.id, f.name,
- (SELECT COUNT(*) FROM federation_chats WHERE federation_id = f.id) AS chat_count,
- (SELECT COUNT(*) FROM federation_bans WHERE federation_id = f.id) AS ban_count
- FROM federations f
- WHERE f.owner_id = $1
- ORDER BY f.created_at ASC]],
- { user_id }
- )
+ local owned = ctx.db.call('sp_get_owned_federations', { user_id })
- -- Federations where user is an admin (but not owner)
- local admin_of = ctx.db.execute(
- [[SELECT f.id, f.name, f.owner_id,
- (SELECT COUNT(*) FROM federation_chats WHERE federation_id = f.id) AS chat_count,
- (SELECT COUNT(*) FROM federation_bans WHERE federation_id = f.id) AS ban_count
- FROM federations f
- JOIN federation_admins fa ON f.id = fa.federation_id
- WHERE fa.user_id = $1 AND f.owner_id != $1
- ORDER BY fa.promoted_at ASC]],
- { user_id }
- )
+ local admin_of = ctx.db.call('sp_get_admin_federations', { user_id })
local has_owned = owned and #owned > 0
local has_admin = admin_of and #admin_of > 0
if not has_owned and not has_admin then
return api.send_message(
message.chat.id,
'You do not own or administrate any federations.',
'html'
)
end
local output = '<b>Your Federations</b>\n'
if has_owned then
output = output .. string.format('\n<b>Owned (%d):</b>', #owned)
for i, fed in ipairs(owned) do
output = output .. string.format(
'\n%d. <b>%s</b>\n ID: <code>%s</code>\n Chats: %d | Bans: %d',
i,
tools.escape_html(fed.name),
tools.escape_html(fed.id),
tonumber(fed.chat_count) or 0,
tonumber(fed.ban_count) or 0
)
end
end
if has_admin then
output = output .. string.format('\n\n<b>Admin of (%d):</b>', #admin_of)
for i, fed in ipairs(admin_of) do
output = output .. string.format(
'\n%d. <b>%s</b>\n ID: <code>%s</code>\n Chats: %d | Bans: %d',
i,
tools.escape_html(fed.name),
tools.escape_html(fed.id),
tonumber(fed.chat_count) or 0,
tonumber(fed.ban_count) or 0
)
end
end
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/newfed.lua b/src/plugins/admin/federation/newfed.lua
index 7ac367d..09d53e1 100644
--- a/src/plugins/admin/federation/newfed.lua
+++ b/src/plugins/admin/federation/newfed.lua
@@ -1,73 +1,66 @@
--[[
mattata v2.0 - Federation: newfed
Creates a new federation. Any user can create up to 5 federations.
]]
local tools = require('telegram-bot-lua.tools')
local plugin = {}
plugin.name = 'newfed'
plugin.category = 'admin'
plugin.description = 'Create a new federation.'
plugin.commands = { 'newfed' }
plugin.help = '/newfed <name> - Create a new federation with the given name.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local name = message.args
if not name or name == '' then
return api.send_message(
message.chat.id,
'Please specify a name for the federation.\nUsage: <code>/newfed &lt;name&gt;</code>',
'html'
)
end
if #name > 128 then
return api.send_message(
message.chat.id,
'Federation name must be 128 characters or fewer.',
'html'
)
end
local user_id = message.from.id
- -- Check how many federations this user already owns
- local existing = ctx.db.execute(
- 'SELECT COUNT(*) AS count FROM federations WHERE owner_id = $1',
- { user_id }
- )
+ local existing = ctx.db.call('sp_count_user_federations', { user_id })
if existing and existing[1] and tonumber(existing[1].count) >= 5 then
return api.send_message(
message.chat.id,
'You already own 5 federations, which is the maximum allowed.',
'html'
)
end
- local result = ctx.db.execute(
- 'INSERT INTO federations (name, owner_id) VALUES ($1, $2) RETURNING id',
- { name, user_id }
- )
+ local result = ctx.db.call('sp_create_federation', { name, user_id })
if not result or #result == 0 then
return api.send_message(
message.chat.id,
'Failed to create the federation. Please try again later.',
'html'
)
end
local fed_id = result[1].id
local output = string.format(
'Federation <b>%s</b> created successfully!\n\nFederation ID: <code>%s</code>\n\nUse <code>/joinfed %s</code> in a group to add it to this federation.',
tools.escape_html(name),
tools.escape_html(fed_id),
tools.escape_html(fed_id)
)
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/federation/unfban.lua b/src/plugins/admin/federation/unfban.lua
index 13de261..3b6427b 100644
--- a/src/plugins/admin/federation/unfban.lua
+++ b/src/plugins/admin/federation/unfban.lua
@@ -1,145 +1,126 @@
--[[
mattata v2.0 - Federation: unfban
Unbans a user from the federation and all its chats.
Only the federation owner or a federation admin can unfban.
]]
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local plugin = {}
plugin.name = 'unfban'
plugin.category = 'admin'
plugin.description = 'Unban a user from the federation.'
plugin.commands = { 'unfban' }
plugin.help = '/unfban [user] - Unban a user from all chats in this federation.'
plugin.group_only = true
plugin.admin_only = false
local function resolve_user(message, ctx)
if message.reply and message.reply.from then
return message.reply.from.id, message.reply.from.first_name
end
if message.args and message.args ~= '' then
local input = message.args:match('^(%S+)')
if tonumber(input) then
return tonumber(input), input
end
local username = input:gsub('^@', ''):lower()
local user_id = ctx.redis.get('username:' .. username)
if user_id then
return tonumber(user_id), '@' .. username
end
end
return nil, nil
end
local function get_chat_federation(db, chat_id)
- local result = db.execute(
- 'SELECT f.id, f.name, f.owner_id FROM federations f JOIN federation_chats fc ON f.id = fc.federation_id WHERE fc.chat_id = $1',
- { chat_id }
- )
+ local result = db.call('sp_get_chat_federation', { chat_id })
if result and #result > 0 then return result[1] end
return nil
end
local function is_fed_admin(db, fed_id, user_id)
- local result = db.execute(
- 'SELECT 1 FROM federation_admins WHERE federation_id = $1 AND user_id = $2',
- { fed_id, user_id }
- )
+ local result = db.call('sp_check_federation_admin', { fed_id, user_id })
return result and #result > 0
end
function plugin.on_message(api, message, ctx)
if message.chat.type ~= 'private' and not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to enforce federation unbans.')
end
local fed = get_chat_federation(ctx.db, message.chat.id)
if not fed then
return api.send_message(
message.chat.id,
'This chat is not part of any federation.',
'html'
)
end
local from_id = message.from.id
if fed.owner_id ~= from_id and not is_fed_admin(ctx.db, fed.id, from_id) then
return api.send_message(
message.chat.id,
'Only the federation owner or a federation admin can use this command.',
'html'
)
end
local target_id, target_name = resolve_user(message, ctx)
if not target_id then
return api.send_message(
message.chat.id,
'Please specify a user to unban by replying to their message or providing a user ID/username.\nUsage: <code>/unfban [user]</code>',
'html'
)
end
- -- Check if the user is actually banned
- local ban = ctx.db.execute(
- 'SELECT 1 FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ local ban = ctx.db.call('sp_check_federation_ban_exists', { fed.id, target_id })
if not ban or #ban == 0 then
return api.send_message(
message.chat.id,
string.format(
'<b>%s</b> (<code>%s</code>) is not banned in this federation.',
tools.escape_html(target_name),
target_id
),
'html'
)
end
- -- Remove the ban record
- ctx.db.execute(
- 'DELETE FROM federation_bans WHERE federation_id = $1 AND user_id = $2',
- { fed.id, target_id }
- )
+ ctx.db.call('sp_delete_federation_ban', { fed.id, target_id })
- -- Invalidate Redis cache
ctx.redis.del(string.format('fban:%s:%s', fed.id, target_id))
- -- Unban in all federation chats
- local chats = ctx.db.execute(
- 'SELECT chat_id FROM federation_chats WHERE federation_id = $1',
- { fed.id }
- )
+ local chats = ctx.db.call('sp_get_federation_chats', { fed.id })
local success_count = 0
local fail_count = 0
if chats then
for _, chat in ipairs(chats) do
local ok = api.unban_chat_member(chat.chat_id, target_id)
if ok then
success_count = success_count + 1
else
fail_count = fail_count + 1
end
end
end
local output = string.format(
'<b>Federation Unban</b>\nFederation: <b>%s</b>\nUser: <b>%s</b> (<code>%s</code>)\nUnbanned by: %s\nUnbanned in %d/%d chats.',
tools.escape_html(fed.name),
tools.escape_html(target_name),
target_id,
tools.escape_html(message.from.first_name),
success_count,
success_count + fail_count
)
return api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/filter.lua b/src/plugins/admin/filter.lua
index 15aba93..25b74a9 100644
--- a/src/plugins/admin/filter.lua
+++ b/src/plugins/admin/filter.lua
@@ -1,74 +1,63 @@
--[[
mattata v2.0 - Filter Plugin
]]
local plugin = {}
plugin.name = 'filter'
plugin.category = 'admin'
plugin.description = 'Add content filters to the group'
plugin.commands = { 'filter', 'addfilter' }
plugin.help = '/filter <pattern> [action] - Adds a filter. Actions: delete (default), warn, ban, kick.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
return api.send_message(message.chat.id, 'Usage: /filter <pattern> [action]\nActions: delete (default), warn, ban, kick, mute', 'html')
end
local pattern, action
if message.args:match('^(.+)%s+(delete|warn|ban|kick|mute)$') then
pattern, action = message.args:match('^(.+)%s+(delete|warn|ban|kick|mute)$')
else
pattern = message.args
action = 'delete'
end
pattern = pattern:match('^%s*(.-)%s*$') -- trim
if pattern == '' then
return api.send_message(message.chat.id, 'Please provide a pattern to filter.')
end
- -- Validate regex pattern
+ -- validate regex pattern
local ok = pcall(string.match, '', pattern)
if not ok then
return api.send_message(message.chat.id, 'Invalid pattern. Please provide a valid Lua pattern.')
end
- -- Check for duplicate
- local existing = ctx.db.execute(
- 'SELECT id FROM filters WHERE chat_id = $1 AND pattern = $2',
- { message.chat.id, pattern }
- )
+ -- check for duplicate
+ local existing = ctx.db.call('sp_get_filter', { message.chat.id, pattern })
if existing and #existing > 0 then
- -- Update the action if filter already exists
- ctx.db.execute(
- 'UPDATE filters SET action = $1 WHERE chat_id = $2 AND pattern = $3',
- { action, message.chat.id, pattern }
- )
+ -- update the action if filter already exists
+ ctx.db.call('sp_update_filter_action', { action, message.chat.id, pattern })
require('src.core.session').invalidate_cached_list(message.chat.id, 'filters')
return api.send_message(message.chat.id, string.format(
'Filter <code>%s</code> updated with action: <b>%s</b>.',
tools.escape_html(pattern), action
), 'html')
end
- ctx.db.insert('filters', {
- chat_id = message.chat.id,
- pattern = pattern,
- action = action,
- created_by = message.from.id
- })
+ ctx.db.call('sp_insert_filter', { message.chat.id, pattern, action, message.from.id })
- -- Invalidate filter cache
+ -- invalidate filter cache
require('src.core.session').invalidate_cached_list(message.chat.id, 'filters')
api.send_message(message.chat.id, string.format(
'Filter added: <code>%s</code> (action: <b>%s</b>)',
tools.escape_html(pattern), action
), 'html')
end
return plugin
diff --git a/src/plugins/admin/groups.lua b/src/plugins/admin/groups.lua
index d8c06db..c1450fc 100644
--- a/src/plugins/admin/groups.lua
+++ b/src/plugins/admin/groups.lua
@@ -1,59 +1,54 @@
--[[
mattata v2.0 - Groups Plugin
]]
local plugin = {}
plugin.name = 'groups'
plugin.category = 'admin'
plugin.description = 'List known groups the bot is in'
plugin.commands = { 'groups' }
plugin.help = '/groups [search] - Lists groups the bot is aware of.'
plugin.group_only = false
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local search = message.args and message.args:lower() or nil
local result
if search then
- result = ctx.db.execute(
- "SELECT chat_id, title, username FROM chats WHERE chat_type IN ('group', 'supergroup') AND LOWER(title) LIKE $1 ORDER BY title LIMIT 50",
- { '%' .. search .. '%' }
- )
+ result = ctx.db.call('sp_search_groups', { '%' .. search .. '%' })
else
- result = ctx.db.execute(
- "SELECT chat_id, title, username FROM chats WHERE chat_type IN ('group', 'supergroup') ORDER BY title LIMIT 50"
- )
+ result = ctx.db.call('sp_list_groups', {})
end
if not result or #result == 0 then
if search then
return api.send_message(message.chat.id, 'No groups found matching that search.')
end
return api.send_message(message.chat.id, 'No groups found in the database.')
end
local output = '<b>Known groups'
if search then
output = output .. ' matching "' .. tools.escape_html(search) .. '"'
end
output = output .. ':</b>\n\n'
for i, row in ipairs(result) do
local title = tools.escape_html(row.title or 'Unknown')
if row.username then
output = output .. string.format('%d. <a href="https://t.me/%s">%s</a>\n', i, row.username, title)
else
output = output .. string.format('%d. %s (<code>%s</code>)\n', i, title, row.chat_id)
end
end
if #result == 50 then
output = output .. '\n<i>Showing first 50 results. Use /groups <search> to narrow down.</i>'
end
api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/kick.lua b/src/plugins/admin/kick.lua
index 8681f2e..132ddea 100644
--- a/src/plugins/admin/kick.lua
+++ b/src/plugins/admin/kick.lua
@@ -1,75 +1,72 @@
--[[
mattata v2.0 - Kick Plugin
]]
local plugin = {}
plugin.name = 'kick'
plugin.category = 'admin'
plugin.description = 'Kick users from a group'
plugin.commands = { 'kick' }
plugin.help = '/kick [user] [reason] - Kicks a user from the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to kick.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t kick an admin or moderator.')
end
- -- Kick = ban + immediate unban
+ -- kick = ban + immediate unban
local success = api.ban_chat_member(message.chat.id, user_id)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to kick users.')
end
api.unban_chat_member(message.chat.id, user_id)
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id, admin_id = message.from.id,
- target_id = user_id, action = 'kick', reason = reason
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'kick', reason })
end)
if reason and reason:lower():match('^for ') then reason = reason:sub(5) end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and ('\nReason: ' .. tools.escape_html(reason)) or ''
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has kicked <a href="tg://user?id=%d">%s</a>.%s',
message.from.id, admin_name, user_id, target_name, reason_text
), 'html')
if message.reply then
pcall(function() api.delete_message(message.chat.id, message.reply.message_id) end)
end
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
end
return plugin
diff --git a/src/plugins/admin/logchat.lua b/src/plugins/admin/logchat.lua
index ca5699a..1a22955 100644
--- a/src/plugins/admin/logchat.lua
+++ b/src/plugins/admin/logchat.lua
@@ -1,58 +1,48 @@
--[[
mattata v2.0 - Log Chat Plugin
]]
local plugin = {}
plugin.name = 'logchat'
plugin.category = 'admin'
plugin.description = 'Set a log chat for admin actions'
plugin.commands = { 'logchat' }
plugin.help = '/logchat <chat_id|off> - Sets the log chat for admin actions.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
- local result = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'log_chat'",
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'log_chat' })
if result and #result > 0 and result[1].value then
return api.send_message(message.chat.id, string.format(
'Admin actions are being logged to <code>%s</code>.\nUse /logchat off to disable.',
result[1].value
), 'html')
end
return api.send_message(message.chat.id, 'No log chat is set. Use /logchat <chat_id> to set one.')
end
local arg = message.args:lower()
if arg == 'off' or arg == 'disable' or arg == 'none' then
- ctx.db.execute(
- "DELETE FROM chat_settings WHERE chat_id = $1 AND key = 'log_chat'",
- { message.chat.id }
- )
+ ctx.db.call('sp_delete_chat_setting', { message.chat.id, 'log_chat' })
return api.send_message(message.chat.id, 'Log chat has been disabled.')
end
local log_chat_id = tonumber(message.args)
if not log_chat_id then
return api.send_message(message.chat.id, 'Please provide a valid chat ID or "off" to disable.')
end
- -- Verify bot can send to the log chat
+ -- verify bot can send to the log chat
local test = api.send_message(log_chat_id, 'This chat has been set as the log chat for admin actions.', nil, nil, nil, nil, nil)
if not test then
return api.send_message(message.chat.id, 'I can\'t send messages to that chat. Make sure I\'m a member there.')
end
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'log_chat',
- value = tostring(log_chat_id)
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'log_chat', tostring(log_chat_id) })
api.send_message(message.chat.id, string.format('Log chat set to <code>%d</code>.', log_chat_id), 'html')
end
return plugin
diff --git a/src/plugins/admin/mute.lua b/src/plugins/admin/mute.lua
index 8a1bdcb..94620e6 100644
--- a/src/plugins/admin/mute.lua
+++ b/src/plugins/admin/mute.lua
@@ -1,82 +1,79 @@
--[[
mattata v2.0 - Mute Plugin
]]
local plugin = {}
plugin.name = 'mute'
plugin.category = 'admin'
plugin.description = 'Mute users in a group'
plugin.commands = { 'mute' }
plugin.help = '/mute [user] [reason] - Mutes a user in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Restrict Members" admin permission to use this command.')
end
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to mute.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t mute an admin or moderator.')
end
local perms = {
can_send_messages = false,
can_send_audios = false,
can_send_documents = false,
can_send_photos = false,
can_send_videos = false,
can_send_video_notes = false,
can_send_voice_notes = false,
can_send_polls = false,
can_send_other_messages = false,
can_add_web_page_previews = false
}
local success = api.restrict_chat_member(message.chat.id, user_id, perms)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to mute users.')
end
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id, admin_id = message.from.id,
- target_id = user_id, action = 'mute', reason = reason
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'mute', reason })
end)
if reason and reason:lower():match('^for ') then reason = reason:sub(5) end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and (', for ' .. tools.escape_html(reason)) or ''
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has muted <a href="tg://user?id=%d">%s</a>%s.',
message.from.id, admin_name, user_id, target_name, reason_text
), 'html')
end
return plugin
diff --git a/src/plugins/admin/promote.lua b/src/plugins/admin/promote.lua
index 435e70f..078c7d7 100644
--- a/src/plugins/admin/promote.lua
+++ b/src/plugins/admin/promote.lua
@@ -1,61 +1,52 @@
--[[
mattata v2.0 - Promote Plugin
]]
local plugin = {}
plugin.name = 'promote'
plugin.category = 'admin'
plugin.description = 'Promote a user to moderator'
plugin.commands = { 'promote' }
plugin.help = '/promote [user] - Promotes a user to moderator in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to promote, either by replying to their message or providing a username/ID.')
end
if user_id == api.info.id then return end
if permissions.is_group_mod(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is already a moderator.')
end
- ctx.db.upsert('chat_members', {
- chat_id = message.chat.id,
- user_id = user_id,
- role = 'moderator'
- }, { 'chat_id', 'user_id' }, { 'role' })
+ ctx.db.call('sp_set_member_role', { message.chat.id, user_id, 'moderator' })
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- target_id = user_id,
- action = 'promote'
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'promote', nil })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has promoted <a href="tg://user?id=%d">%s</a> to moderator.',
message.from.id, admin_name, user_id, target_name
), 'html')
end
return plugin
diff --git a/src/plugins/admin/purge.lua b/src/plugins/admin/purge.lua
index 6cfd735..c28ca04 100644
--- a/src/plugins/admin/purge.lua
+++ b/src/plugins/admin/purge.lua
@@ -1,58 +1,53 @@
--[[
mattata v2.0 - Purge Plugin
]]
local plugin = {}
plugin.name = 'purge'
plugin.category = 'admin'
plugin.description = 'Delete messages in bulk'
plugin.commands = { 'purge' }
plugin.help = '/purge - Deletes all messages from the replied-to message up to the command message.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local permissions = require('src.core.permissions')
if not permissions.can_delete(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Delete Messages" admin permission to use this command.')
end
if not message.reply then
return api.send_message(message.chat.id, 'Please reply to the first message you want to delete, and all messages from that point to your command will be purged.')
end
local start_id = message.reply.message_id
local end_id = message.message_id
local count = 0
local failed = 0
for msg_id = start_id, end_id do
local success = api.delete_message(message.chat.id, msg_id)
if success then
count = count + 1
else
failed = failed + 1
end
end
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- action = 'purge',
- reason = string.format('Purged %d messages (%d failed)', count, failed)
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, nil, 'purge', string.format('Purged %d messages (%d failed)', count, failed) })
end)
local status = api.send_message(message.chat.id, string.format('Purged <b>%d</b> message(s).', count), 'html')
- -- Auto-delete the status message after a short delay
+ -- auto-delete the status message after a short delay
if status and status.result then
pcall(function()
local socket = require('socket')
socket.sleep(3)
api.delete_message(message.chat.id, status.result.message_id)
end)
end
end
return plugin
diff --git a/src/plugins/admin/rules.lua b/src/plugins/admin/rules.lua
index dd54f37..acecc8d 100644
--- a/src/plugins/admin/rules.lua
+++ b/src/plugins/admin/rules.lua
@@ -1,43 +1,37 @@
--[[
mattata v2.0 - Rules Plugin
]]
local plugin = {}
plugin.name = 'rules'
plugin.category = 'admin'
plugin.description = 'Display group rules'
plugin.commands = { 'rules' }
plugin.help = '/rules - Displays the group rules. Admins can set rules with /setrules <text>.'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
- -- If /rules is used with args and user is admin, set rules
+ -- if /rules is used with args and user is admin, set rules
if message.args and (ctx.is_admin or ctx.is_global_admin) then
- ctx.db.upsert('rules', {
- chat_id = message.chat.id,
- rules_text = message.args
- }, { 'chat_id' }, { 'rules_text' })
+ ctx.db.call('sp_upsert_rules', { message.chat.id, message.args })
return api.send_message(message.chat.id, 'The rules have been updated.')
end
- -- Retrieve rules
- local result = ctx.db.execute(
- 'SELECT rules_text FROM rules WHERE chat_id = $1',
- { message.chat.id }
- )
+ -- retrieve rules
+ local result = ctx.db.call('sp_get_rules', { message.chat.id })
if not result or #result == 0 or not result[1].rules_text then
return api.send_message(message.chat.id, 'No rules have been set for this group. An admin can set them with /rules <text>.')
end
local output = string.format(
'<b>Rules for %s:</b>\n\n%s',
tools.escape_html(message.chat.title or 'this chat'),
result[1].rules_text
)
api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/save.lua b/src/plugins/admin/save.lua
index f2e3ce1..78a1f2d 100644
--- a/src/plugins/admin/save.lua
+++ b/src/plugins/admin/save.lua
@@ -1,121 +1,108 @@
--[[
mattata v2.0 - Save/Get Notes Plugin
]]
local plugin = {}
plugin.name = 'save'
plugin.category = 'admin'
plugin.description = 'Save and retrieve notes'
plugin.commands = { 'save', 'get' }
plugin.help = '/save <name> - Saves replied-to message as a note. /get <name> - Retrieves a saved note.'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if message.command == 'get' then
if not message.args then
- -- List all saved notes
- local notes = ctx.db.execute(
- 'SELECT note_name FROM saved_notes WHERE chat_id = $1 ORDER BY note_name',
- { message.chat.id }
- )
+ -- list all saved notes
+ local notes = ctx.db.call('sp_list_notes', { message.chat.id })
if not notes or #notes == 0 then
return api.send_message(message.chat.id, 'No notes saved. An admin can save notes with /save <name> in reply to a message.')
end
local output = '<b>Saved notes:</b>\n\n'
for _, note in ipairs(notes) do
output = output .. '- <code>' .. tools.escape_html(note.note_name) .. '</code>\n'
end
return api.send_message(message.chat.id, output, 'html')
end
local name = message.args:lower():match('^(%S+)')
- local note = ctx.db.execute(
- 'SELECT content, content_type, file_id FROM saved_notes WHERE chat_id = $1 AND note_name = $2',
- { message.chat.id, name }
- )
+ local note = ctx.db.call('sp_get_note', { message.chat.id, name })
if not note or #note == 0 then
return api.send_message(message.chat.id, string.format('Note <code>%s</code> not found.', tools.escape_html(name)), 'html')
end
local n = note[1]
if n.content_type == 'photo' and n.file_id then
api.send_photo(message.chat.id, n.file_id, n.content)
elseif n.content_type == 'document' and n.file_id then
api.send_document(message.chat.id, n.file_id, n.content)
elseif n.content_type == 'video' and n.file_id then
api.send_video(message.chat.id, n.file_id, nil, nil, nil, n.content)
elseif n.content_type == 'audio' and n.file_id then
api.send_audio(message.chat.id, n.file_id, n.content)
elseif n.content_type == 'sticker' and n.file_id then
api.send_sticker(message.chat.id, n.file_id)
else
api.send_message(message.chat.id, n.content, 'html')
end
return
end
-- /save requires admin
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'Only admins can save notes.')
end
if not message.args then
return api.send_message(message.chat.id, 'Usage: /save <name> in reply to a message.')
end
local name = message.args:lower():match('^(%S+)')
if not name then
return api.send_message(message.chat.id, 'Please provide a name for the note.')
end
local content = ''
local content_type = 'text'
local file_id = nil
if message.reply then
content = message.reply.text or message.reply.caption or ''
if message.reply.photo then
content_type = 'photo'
file_id = message.reply.photo[#message.reply.photo].file_id
elseif message.reply.document then
content_type = 'document'
file_id = message.reply.document.file_id
elseif message.reply.video then
content_type = 'video'
file_id = message.reply.video.file_id
elseif message.reply.audio then
content_type = 'audio'
file_id = message.reply.audio.file_id
elseif message.reply.sticker then
content_type = 'sticker'
file_id = message.reply.sticker.file_id
end
else
- -- If no reply, save the text after the note name
+ -- if no reply, save the text after the note name
local _, rest = message.args:match('^(%S+)%s+(.+)$')
if rest then
content = rest
else
return api.send_message(message.chat.id, 'Please reply to a message or provide text after the note name.')
end
end
- ctx.db.upsert('saved_notes', {
- chat_id = message.chat.id,
- note_name = name,
- content = content,
- content_type = content_type,
- file_id = file_id,
- created_by = message.from.id
- }, { 'chat_id', 'note_name' }, { 'content', 'content_type', 'file_id', 'created_by' })
+ ctx.db.call('sp_upsert_note', { message.chat.id, name, content, content_type, file_id, message.from.id })
api.send_message(message.chat.id, string.format(
'Note <code>%s</code> has been saved. Use /get %s to retrieve it.',
tools.escape_html(name), tools.escape_html(name)
), 'html')
end
return plugin
diff --git a/src/plugins/admin/setgrouplang.lua b/src/plugins/admin/setgrouplang.lua
index 6340fb7..ca6ce66 100644
--- a/src/plugins/admin/setgrouplang.lua
+++ b/src/plugins/admin/setgrouplang.lua
@@ -1,120 +1,102 @@
--[[
mattata v2.0 - Set Group Language Plugin
]]
local plugin = {}
plugin.name = 'setgrouplang'
plugin.category = 'admin'
plugin.description = 'Set the group language'
plugin.commands = { 'setgrouplang' }
plugin.help = '/setgrouplang [language_code] - Sets the group language. Shows available languages if no code given.'
plugin.group_only = true
plugin.admin_only = true
local LANGUAGES = {
{ code = 'en_gb', name = 'English (UK)' },
{ code = 'en_us', name = 'English (US)' },
{ code = 'es_es', name = 'Spanish' },
{ code = 'pt_br', name = 'Portuguese (BR)' },
{ code = 'de_de', name = 'German' },
{ code = 'fr_fr', name = 'French' },
{ code = 'it_it', name = 'Italian' },
{ code = 'ru_ru', name = 'Russian' },
{ code = 'ar_sa', name = 'Arabic' },
{ code = 'tr_tr', name = 'Turkish' },
{ code = 'nl_nl', name = 'Dutch' },
{ code = 'pl_pl', name = 'Polish' },
{ code = 'id_id', name = 'Indonesian' },
{ code = 'uk_ua', name = 'Ukrainian' },
{ code = 'he_il', name = 'Hebrew' },
{ code = 'fa_ir', name = 'Persian' }
}
function plugin.on_message(api, message, ctx)
if not message.args then
- -- Show inline keyboard with available languages
+ -- show inline keyboard with available languages
local keyboard = { inline_keyboard = {} }
local row = {}
for i, lang in ipairs(LANGUAGES) do
table.insert(row, {
text = lang.name,
callback_data = 'setgrouplang:' .. lang.code
})
if #row == 2 or i == #LANGUAGES then
table.insert(keyboard.inline_keyboard, row)
row = {}
end
end
local json = require('dkjson')
return api.send_message(message.chat.id, '<b>Select the group language:</b>', 'html', false, false, nil, json.encode(keyboard))
end
local lang_code = message.args:lower():match('^(%S+)$')
if not lang_code then
return api.send_message(message.chat.id, 'Please provide a valid language code.')
end
- -- Validate the language code
+ -- validate the language code
local valid = false
for _, lang in ipairs(LANGUAGES) do
if lang.code == lang_code then
valid = true
break
end
end
if not valid then
return api.send_message(message.chat.id, 'Invalid language code. Use /setgrouplang to see available options.')
end
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'group_language',
- value = lang_code
- }, { 'chat_id', 'key' }, { 'value' })
-
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'force_group_language',
- value = 'true'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'group_language', lang_code })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'force_group_language', 'true' })
api.send_message(message.chat.id, string.format('Group language set to <b>%s</b>.', lang_code), 'html')
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local permissions = require('src.core.permissions')
if not permissions.is_group_admin(api, message.chat.id, callback_query.from.id) then
return api.answer_callback_query(callback_query.id, 'Only admins can change the group language.')
end
local lang_code = callback_query.data
if not lang_code then return end
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'group_language',
- value = lang_code
- }, { 'chat_id', 'key' }, { 'value' })
-
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'force_group_language',
- value = 'true'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'group_language', lang_code })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'force_group_language', 'true' })
- -- Find language name
+ -- find language name
local lang_name = lang_code
for _, lang in ipairs(LANGUAGES) do
if lang.code == lang_code then
lang_name = lang.name
break
end
end
api.edit_message_text(message.chat.id, message.message_id, string.format(
'Group language set to <b>%s</b> (%s).', lang_name, lang_code
), 'html')
api.answer_callback_query(callback_query.id, 'Language updated!')
end
return plugin
diff --git a/src/plugins/admin/setwelcome.lua b/src/plugins/admin/setwelcome.lua
index a2df046..8d7d60b 100644
--- a/src/plugins/admin/setwelcome.lua
+++ b/src/plugins/admin/setwelcome.lua
@@ -1,43 +1,37 @@
--[[
mattata v2.0 - Set Welcome Plugin
]]
local plugin = {}
plugin.name = 'setwelcome'
plugin.category = 'admin'
plugin.description = 'Set the welcome message for new members'
plugin.commands = { 'setwelcome', 'welcome' }
plugin.help = '/setwelcome <message> - Sets the welcome message. Placeholders: $name, $title, $id, $username, $mention'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if message.command == 'welcome' and not message.args then
- -- Show current welcome message
- local result = ctx.db.execute(
- 'SELECT message FROM welcome_messages WHERE chat_id = $1',
- { message.chat.id }
- )
+ -- show current welcome message
+ local result = ctx.db.call('sp_get_welcome_message', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No welcome message has been set. Use /setwelcome <message> to set one.')
end
return api.send_message(message.chat.id, '<b>Current welcome message:</b>\n\n' .. result[1].message, 'html')
end
if not message.args then
return api.send_message(message.chat.id,
'Please provide the welcome message text.\n\n'
.. 'Placeholders: <code>$name</code>, <code>$title</code>, '
.. '<code>$id</code>, <code>$username</code>, <code>$mention</code>',
'html')
end
- ctx.db.upsert('welcome_messages', {
- chat_id = message.chat.id,
- message = message.args
- }, { 'chat_id' }, { 'message' })
+ ctx.db.call('sp_upsert_welcome_message', { message.chat.id, message.args })
api.send_message(message.chat.id, 'The welcome message has been updated.')
end
return plugin
diff --git a/src/plugins/admin/staff.lua b/src/plugins/admin/staff.lua
index 9cbeac4..85af291 100644
--- a/src/plugins/admin/staff.lua
+++ b/src/plugins/admin/staff.lua
@@ -1,77 +1,74 @@
--[[
mattata v2.0 - Staff Plugin
]]
local plugin = {}
plugin.name = 'staff'
plugin.category = 'admin'
plugin.description = 'List group staff (admins and moderators)'
plugin.commands = { 'staff', 'admins', 'mods' }
plugin.help = '/staff - Lists all admins and moderators in the current chat. Aliases: /admins, /mods'
plugin.group_only = true
plugin.admin_only = false
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
- -- Get Telegram admins
+ -- get telegram admins
local admins = api.get_chat_administrators(message.chat.id)
if not admins or not admins.result then
return api.send_message(message.chat.id, 'I couldn\'t retrieve the admin list.')
end
local output = '<b>Staff for ' .. tools.escape_html(message.chat.title or 'this chat') .. '</b>\n\n'
- -- Creator
+ -- creator
local creator_text = ''
for _, admin in ipairs(admins.result) do
if admin.status == 'creator' then
local name = tools.escape_html(admin.user.first_name)
creator_text = string.format(
'<a href="tg://user?id=%d">%s</a>',
admin.user.id, name
)
break
end
end
if creator_text ~= '' then
output = output .. '<b>Owner:</b>\n' .. creator_text .. '\n\n'
end
- -- Admins
+ -- admins
local admin_list = {}
for _, admin in ipairs(admins.result) do
if admin.status == 'administrator' and not admin.user.is_bot then
local name = tools.escape_html(admin.user.first_name)
table.insert(admin_list, string.format(
'- <a href="tg://user?id=%d">%s</a>',
admin.user.id, name
))
end
end
if #admin_list > 0 then
output = output .. '<b>Admins (' .. #admin_list .. '):</b>\n' .. table.concat(admin_list, '\n') .. '\n\n'
end
- -- Moderators (from database)
- local mods = ctx.db.execute(
- "SELECT user_id FROM chat_members WHERE chat_id = $1 AND role = 'moderator'",
- { message.chat.id }
- )
+ -- moderators (from database)
+ local mods = ctx.db.call('sp_get_moderators', { message.chat.id })
if mods and #mods > 0 then
local mod_list = {}
for _, mod in ipairs(mods) do
local info = api.get_chat(mod.user_id)
local name = info and info.result and tools.escape_html(info.result.first_name) or tostring(mod.user_id)
table.insert(mod_list, string.format(
'- <a href="tg://user?id=%s">%s</a>',
mod.user_id, name
))
end
output = output .. '<b>Moderators (' .. #mod_list .. '):</b>\n' .. table.concat(mod_list, '\n') .. '\n'
end
api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/tempban.lua b/src/plugins/admin/tempban.lua
index 2ffc115..8382066 100644
--- a/src/plugins/admin/tempban.lua
+++ b/src/plugins/admin/tempban.lua
@@ -1,88 +1,85 @@
--[[
mattata v2.0 - Tempban Plugin
]]
local plugin = {}
plugin.name = 'tempban'
plugin.category = 'admin'
plugin.description = 'Temporarily ban users'
plugin.commands = { 'tempban', 'tban' }
plugin.help = '/tempban [user] <duration> - Temporarily bans a user. Duration format: 1h, 2d, 1w.'
plugin.group_only = true
plugin.admin_only = true
local function parse_duration(str)
if not str then return nil end
local total = 0
for num, unit in str:gmatch('(%d+)(%a)') do
num = tonumber(num)
if unit == 's' then total = total + num
elseif unit == 'm' then total = total + num * 60
elseif unit == 'h' then total = total + num * 3600
elseif unit == 'd' then total = total + num * 86400
elseif unit == 'w' then total = total + num * 604800
end
end
return total > 0 and total or nil
end
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, duration_str
if message.reply and message.reply.from then
user_id = message.reply.from.id
duration_str = message.args
elseif message.args then
user_id, duration_str = message.args:match('^(%S+)%s+(.+)$')
if not user_id then
user_id = message.args
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user and duration. Example: /tempban @user 2h')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
local duration = parse_duration(duration_str)
if not duration or duration < 60 then
return api.send_message(message.chat.id, 'Please provide a valid duration (minimum 1 minute). Example: 1h, 2d, 1w')
end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t ban an admin.')
end
local until_date = os.time() + duration
local success = api.ban_chat_member(message.chat.id, user_id, until_date)
if not success then
return api.send_message(message.chat.id, 'I don\'t have permission to ban users.')
end
pcall(function()
- ctx.db.insert('bans', {
- chat_id = message.chat.id, user_id = user_id,
- banned_by = message.from.id, expires_at = os.date('!%Y-%m-%d %H:%M:%S', until_date)
- })
+ ctx.db.call('sp_insert_tempban', { message.chat.id, user_id, message.from.id, os.date('!%Y-%m-%d %H:%M:%S', until_date) })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has temporarily banned <a href="tg://user?id=%d">%s</a> for %s.',
message.from.id, admin_name, user_id, target_name, duration_str or 'unknown'
), 'html')
end
return plugin
diff --git a/src/plugins/admin/triggers.lua b/src/plugins/admin/triggers.lua
index ae7ff65..f61da86 100644
--- a/src/plugins/admin/triggers.lua
+++ b/src/plugins/admin/triggers.lua
@@ -1,40 +1,37 @@
--[[
mattata v2.0 - Triggers Plugin
]]
local plugin = {}
plugin.name = 'triggers'
plugin.category = 'admin'
plugin.description = 'List all triggers in the group'
plugin.commands = { 'triggers' }
plugin.help = '/triggers - Lists all triggers set for this group.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
- local triggers = ctx.db.execute(
- 'SELECT id, pattern, response, created_by, created_at FROM triggers WHERE chat_id = $1 ORDER BY created_at',
- { message.chat.id }
- )
+ local triggers = ctx.db.call('sp_get_triggers_full', { message.chat.id })
if not triggers or #triggers == 0 then
return api.send_message(message.chat.id, 'No triggers are set. Use /addtrigger <pattern> <response> to add one.')
end
local output = '<b>Triggers for this group:</b>\n\n'
for i, t in ipairs(triggers) do
output = output .. string.format(
'%d. <code>%s</code> -> %s\n',
i,
tools.escape_html(t.pattern),
tools.escape_html(t.response:sub(1, 50)) .. (#t.response > 50 and '...' or '')
)
end
output = output .. string.format('\n<i>Total: %d trigger(s)</i>', #triggers)
api.send_message(message.chat.id, output, 'html')
end
return plugin
diff --git a/src/plugins/admin/trust.lua b/src/plugins/admin/trust.lua
index 9532808..7874812 100644
--- a/src/plugins/admin/trust.lua
+++ b/src/plugins/admin/trust.lua
@@ -1,61 +1,52 @@
--[[
mattata v2.0 - Trust Plugin
]]
local plugin = {}
plugin.name = 'trust'
plugin.category = 'admin'
plugin.description = 'Trust a user in the group'
plugin.commands = { 'trust' }
plugin.help = '/trust [user] - Marks a user as trusted in the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to trust, either by replying to their message or providing a username/ID.')
end
if user_id == api.info.id then return end
if permissions.is_trusted(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is already trusted.')
end
- ctx.db.upsert('chat_members', {
- chat_id = message.chat.id,
- user_id = user_id,
- role = 'trusted'
- }, { 'chat_id', 'user_id' }, { 'role' })
+ ctx.db.call('sp_set_member_role', { message.chat.id, user_id, 'trusted' })
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- target_id = user_id,
- action = 'trust'
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'trust', nil })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has trusted <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
), 'html')
end
return plugin
diff --git a/src/plugins/admin/unban.lua b/src/plugins/admin/unban.lua
index 63d078a..6de7563 100644
--- a/src/plugins/admin/unban.lua
+++ b/src/plugins/admin/unban.lua
@@ -1,52 +1,49 @@
--[[
mattata v2.0 - Unban Plugin
]]
local plugin = {}
plugin.name = 'unban'
plugin.category = 'admin'
plugin.description = 'Unban users from a group'
plugin.commands = { 'unban' }
plugin.help = '/unban [user] - Unbans a user from the current chat.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args and message.args ~= '' then
local input = message.args:match('^@?(%S+)')
user_id = tonumber(input) or ctx.redis.get('username:' .. input:lower())
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to unban.')
end
user_id = tonumber(user_id)
local success = api.unban_chat_member(message.chat.id, user_id)
if not success then
return api.send_message(message.chat.id, 'I couldn\'t unban that user.')
end
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id, admin_id = message.from.id,
- target_id = user_id, action = 'unban'
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'unban', nil })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
return api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has unbanned <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
), 'html')
end
return plugin
diff --git a/src/plugins/admin/unfilter.lua b/src/plugins/admin/unfilter.lua
index 700eade..2d98cd7 100644
--- a/src/plugins/admin/unfilter.lua
+++ b/src/plugins/admin/unfilter.lua
@@ -1,66 +1,56 @@
--[[
mattata v2.0 - Unfilter Plugin
]]
local plugin = {}
plugin.name = 'unfilter'
plugin.category = 'admin'
plugin.description = 'Remove a content filter from the group'
plugin.commands = { 'unfilter', 'delfilter' }
plugin.help = '/unfilter <pattern> - Removes a filter. Alias: /delfilter'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
if not message.args then
- -- List existing filters
- local filters = ctx.db.execute(
- 'SELECT pattern, action FROM filters WHERE chat_id = $1 ORDER BY created_at',
- { message.chat.id }
- )
+ -- list existing filters
+ local filters = ctx.db.call('sp_get_filters_ordered', { message.chat.id })
if not filters or #filters == 0 then
return api.send_message(message.chat.id, 'There are no filters set for this group.')
end
local output = '<b>Active filters:</b>\n\n'
for i, f in ipairs(filters) do
output = output .. string.format('%d. <code>%s</code> [%s]\n', i, tools.escape_html(f.pattern), f.action)
end
output = output .. '\nUse /unfilter <pattern> to remove a filter.'
return api.send_message(message.chat.id, output, 'html')
end
local pattern = message.args:match('^%s*(.-)%s*$')
- local result = ctx.db.execute(
- 'DELETE FROM filters WHERE chat_id = $1 AND pattern = $2',
- { message.chat.id, pattern }
- )
+ local result = ctx.db.call('sp_delete_filter_by_pattern', { message.chat.id, pattern })
- -- pgmoon returns the number of affected rows in the result
- if result and result.affected_rows and tonumber(result.affected_rows) > 0 then
+ if result and result[1] and tonumber(result[1].count) > 0 then
api.send_message(message.chat.id, string.format(
'Filter <code>%s</code> has been removed.',
tools.escape_html(pattern)
), 'html')
else
- -- Try by index number
+ -- try by index number
local index = tonumber(pattern)
if index then
- local filters = ctx.db.execute(
- 'SELECT id, pattern FROM filters WHERE chat_id = $1 ORDER BY created_at',
- { message.chat.id }
- )
+ local filters = ctx.db.call('sp_get_filters_ordered', { message.chat.id })
if filters and filters[index] then
- ctx.db.execute('DELETE FROM filters WHERE id = $1', { filters[index].id })
+ ctx.db.call('sp_delete_filter_by_id', { filters[index].id })
return api.send_message(message.chat.id, string.format(
'Filter <code>%s</code> has been removed.',
tools.escape_html(filters[index].pattern)
), 'html')
end
end
api.send_message(message.chat.id, 'That filter doesn\'t exist. Use /unfilter without arguments to see all filters.')
end
end
return plugin
diff --git a/src/plugins/admin/untrust.lua b/src/plugins/admin/untrust.lua
index 49f60c5..6e283f4 100644
--- a/src/plugins/admin/untrust.lua
+++ b/src/plugins/admin/untrust.lua
@@ -1,59 +1,51 @@
--[[
mattata v2.0 - Untrust Plugin
]]
local plugin = {}
plugin.name = 'untrust'
plugin.category = 'admin'
plugin.description = 'Remove trusted status from a user'
plugin.commands = { 'untrust' }
plugin.help = '/untrust [user] - Removes trusted status from a user.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
local user_id
if message.reply and message.reply.from then
user_id = message.reply.from.id
elseif message.args then
user_id = message.args:match('^@?(%S+)')
if tonumber(user_id) == nil then
user_id = ctx.redis.get('username:' .. user_id:lower())
end
end
user_id = tonumber(user_id)
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to untrust, either by replying to their message or providing a username/ID.')
end
if not permissions.is_trusted(ctx.db, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'That user is not trusted.')
end
- ctx.db.execute(
- "UPDATE chat_members SET role = 'member' WHERE chat_id = $1 AND user_id = $2",
- { message.chat.id, user_id }
- )
+ ctx.db.call('sp_reset_member_role', { message.chat.id, user_id })
pcall(function()
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id,
- admin_id = message.from.id,
- target_id = user_id,
- action = 'untrust'
- })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'untrust', nil })
end)
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has removed trusted status from <a href="tg://user?id=%d">%s</a>.',
message.from.id, admin_name, user_id, target_name
), 'html')
end
return plugin
diff --git a/src/plugins/admin/warn.lua b/src/plugins/admin/warn.lua
index d099a49..6b4b2eb 100644
--- a/src/plugins/admin/warn.lua
+++ b/src/plugins/admin/warn.lua
@@ -1,137 +1,131 @@
--[[
mattata v2.0 - Warn Plugin
Warning system with configurable max warnings and auto-ban.
]]
local plugin = {}
plugin.name = 'warn'
plugin.category = 'admin'
plugin.description = 'Warn users with auto-ban threshold'
plugin.commands = { 'warn' }
plugin.help = '/warn [user] [reason] - Warns a user. After reaching max warnings, user is banned.'
plugin.group_only = true
plugin.admin_only = true
local DEFAULT_MAX_WARNINGS = 3
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if not permissions.can_restrict(api, message.chat.id) then
return api.send_message(message.chat.id, 'I need the "Ban Users" admin permission to use this command.')
end
local user_id, reason
if message.reply and message.reply.from then
user_id = message.reply.from.id
reason = message.args
elseif message.args then
local input = message.args
if input:match('^(%S+)%s+(.+)$') then
user_id, reason = input:match('^(%S+)%s+(.+)$')
else
user_id = input
end
end
if not user_id then
return api.send_message(message.chat.id, 'Please specify the user to warn.')
end
if tonumber(user_id) == nil then
local name = user_id:match('^@?(.+)$')
user_id = ctx.redis.get('username:' .. name:lower())
end
user_id = tonumber(user_id)
if not user_id or user_id == api.info.id then return end
if permissions.is_group_admin(api, message.chat.id, user_id) then
return api.send_message(message.chat.id, 'I can\'t warn an admin or moderator.')
end
- -- Increment warning count
+ -- increment warning count
local hash = string.format('chat:%s:%s', message.chat.id, user_id)
local amount = ctx.redis.hincrby(hash, 'warnings', 1)
local max_warnings = tonumber(ctx.session.get_setting(message.chat.id, 'max warnings')) or DEFAULT_MAX_WARNINGS
- -- Auto-ban if threshold reached
+ -- auto-ban if threshold reached
if tonumber(amount) >= max_warnings then
api.ban_chat_member(message.chat.id, user_id)
end
- -- Log to database
+ -- log to database
pcall(function()
- ctx.db.insert('warnings', {
- chat_id = message.chat.id, user_id = user_id,
- warned_by = message.from.id, reason = reason
- })
- ctx.db.insert('admin_actions', {
- chat_id = message.chat.id, admin_id = message.from.id,
- target_id = user_id, action = 'warn', reason = reason
- })
+ ctx.db.call('sp_insert_warning', { message.chat.id, user_id, message.from.id, reason })
+ ctx.db.call('sp_log_admin_action', { message.chat.id, message.from.id, user_id, 'warn', reason })
end)
if reason and reason:lower():match('^for ') then reason = reason:sub(5) end
local admin_name = tools.escape_html(message.from.first_name)
local target_info = api.get_chat(user_id)
local target_name = target_info and target_info.result and tools.escape_html(target_info.result.first_name) or tostring(user_id)
local reason_text = reason and (', for ' .. tools.escape_html(reason)) or ''
local output
if tonumber(amount) >= max_warnings then
output = string.format(
'<a href="tg://user?id=%d">%s</a> has warned <a href="tg://user?id=%d">%s</a>%s.\n<b>%d/%d warnings reached - user has been banned.</b>',
message.from.id, admin_name, user_id, target_name, reason_text, amount, max_warnings
)
else
output = string.format(
'<a href="tg://user?id=%d">%s</a> has warned <a href="tg://user?id=%d">%s</a>%s. [%d/%d]',
message.from.id, admin_name, user_id, target_name, reason_text, amount, max_warnings
)
end
local keyboard = api.inline_keyboard():row(
api.row():callback_data_button(
'Reset Warnings', string.format('warn:reset:%s:%s', message.chat.id, user_id)
):callback_data_button(
'Remove 1', string.format('warn:remove:%s:%s', message.chat.id, user_id)
)
)
api.send_message(message.chat.id, output, 'html', true, false, nil, keyboard)
if message.reply then
pcall(function() api.delete_message(message.chat.id, message.reply.message_id) end)
end
pcall(function() api.delete_message(message.chat.id, message.message_id) end)
end
function plugin.on_callback_query(api, callback_query, message, ctx)
local tools = require('telegram-bot-lua.tools')
local permissions = require('src.core.permissions')
if callback_query.data:match('^reset:%-?%d+:%d+$') then
local chat_id, user_id = callback_query.data:match('^reset:(%-?%d+):(%d+)$')
if not permissions.is_group_admin(api, tonumber(chat_id), callback_query.from.id) then
return api.answer_callback_query(callback_query.id, 'You need to be an admin.')
end
ctx.redis.hdel(string.format('chat:%s:%s', chat_id, user_id), 'warnings')
local name = callback_query.from.username and ('@' .. callback_query.from.username) or tools.escape_html(callback_query.from.first_name)
return api.edit_message_text(message.chat.id, message.message_id,
'<pre>Warnings reset by ' .. name .. '!</pre>', 'html')
elseif callback_query.data:match('^remove:%-?%d+:%d+$') then
local chat_id, user_id = callback_query.data:match('^remove:(%-?%d+):(%d+)$')
if not permissions.is_group_admin(api, tonumber(chat_id), callback_query.from.id) then
return api.answer_callback_query(callback_query.id, 'You need to be an admin.')
end
local hash = string.format('chat:%s:%s', chat_id, user_id)
local amount = ctx.redis.hincrby(hash, 'warnings', -1)
if tonumber(amount) < 0 then
ctx.redis.hincrby(hash, 'warnings', 1)
return api.answer_callback_query(callback_query.id, 'No warnings to remove!')
end
local max_warnings = tonumber(ctx.session.get_setting(tonumber(chat_id), 'max warnings')) or DEFAULT_MAX_WARNINGS
local name = callback_query.from.username and ('@' .. callback_query.from.username) or tools.escape_html(callback_query.from.first_name)
return api.edit_message_text(message.chat.id, message.message_id,
string.format('<pre>Warning removed by %s! [%s/%s]</pre>', name, amount, max_warnings), 'html')
end
end
return plugin
diff --git a/src/plugins/admin/wordfilter.lua b/src/plugins/admin/wordfilter.lua
index e9cea85..28b9e32 100644
--- a/src/plugins/admin/wordfilter.lua
+++ b/src/plugins/admin/wordfilter.lua
@@ -1,111 +1,94 @@
--[[
mattata v2.0 - Word Filter Plugin
]]
local plugin = {}
plugin.name = 'wordfilter'
plugin.category = 'admin'
plugin.description = 'Toggle word filter and process filtered messages'
plugin.commands = { 'wordfilter' }
plugin.help = '/wordfilter <on|off> - Toggle word filtering. Filtered words are managed with /filter and /unfilter.'
plugin.group_only = true
plugin.admin_only = true
function plugin.on_message(api, message, ctx)
if not message.args then
- local enabled = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'wordfilter_enabled'",
- { message.chat.id }
- )
+ local enabled = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'wordfilter_enabled' })
local status = (enabled and #enabled > 0 and enabled[1].value == 'true') and 'enabled' or 'disabled'
return api.send_message(message.chat.id, string.format(
'Word filter is currently <b>%s</b>.\nUsage: /wordfilter <on|off>', status
), 'html')
end
local arg = message.args:lower()
if arg == 'on' or arg == 'enable' then
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'wordfilter_enabled',
- value = 'true'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'wordfilter_enabled', 'true' })
require('src.core.session').invalidate_setting(message.chat.id, 'wordfilter_enabled')
return api.send_message(message.chat.id, 'Word filter has been enabled.')
elseif arg == 'off' or arg == 'disable' then
- ctx.db.upsert('chat_settings', {
- chat_id = message.chat.id,
- key = 'wordfilter_enabled',
- value = 'false'
- }, { 'chat_id', 'key' }, { 'value' })
+ ctx.db.call('sp_upsert_chat_setting', { message.chat.id, 'wordfilter_enabled', 'false' })
require('src.core.session').invalidate_setting(message.chat.id, 'wordfilter_enabled')
return api.send_message(message.chat.id, 'Word filter has been disabled.')
else
return api.send_message(message.chat.id, 'Usage: /wordfilter <on|off>')
end
end
function plugin.on_new_message(api, message, ctx)
if not ctx.is_group or not message.text or message.text == '' then return end
if ctx.is_admin or ctx.is_global_admin then return end
if not require('src.core.permissions').can_delete(api, message.chat.id) then return end
- -- Check if wordfilter is enabled (cached)
+ -- check if wordfilter is enabled (cached)
local session = require('src.core.session')
local enabled = session.get_cached_setting(message.chat.id, 'wordfilter_enabled', function()
- local result = ctx.db.execute(
- "SELECT value FROM chat_settings WHERE chat_id = $1 AND key = 'wordfilter_enabled'",
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_chat_setting', { message.chat.id, 'wordfilter_enabled' })
if result and #result > 0 then return result[1].value end
return nil
end, 300)
if enabled ~= 'true' then
return
end
- -- Get filters for this chat (cached)
+ -- get filters for this chat (cached)
local filters = session.get_cached_list(message.chat.id, 'filters', function()
- return ctx.db.execute(
- 'SELECT pattern, action FROM filters WHERE chat_id = $1',
- { message.chat.id }
- )
+ return ctx.db.call('sp_get_filters', { message.chat.id })
end, 300)
if not filters or #filters == 0 then return end
local text = message.text:lower()
for _, f in ipairs(filters) do
local match = pcall(function()
return text:match(f.pattern:lower())
end)
if match and text:match(f.pattern:lower()) then
- -- Execute action
+ -- execute action
if f.action == 'delete' then
api.delete_message(message.chat.id, message.message_id)
elseif f.action == 'warn' then
api.delete_message(message.chat.id, message.message_id)
local hash = string.format('chat:%s:%s', message.chat.id, message.from.id)
ctx.redis.hincrby(hash, 'warnings', 1)
api.send_message(message.chat.id, string.format(
'<a href="tg://user?id=%d">%s</a> has been warned for using a filtered word.',
message.from.id, require('telegram-bot-lua.tools').escape_html(message.from.first_name)
), 'html')
elseif f.action == 'ban' then
api.delete_message(message.chat.id, message.message_id)
api.ban_chat_member(message.chat.id, message.from.id)
elseif f.action == 'kick' then
api.delete_message(message.chat.id, message.message_id)
api.ban_chat_member(message.chat.id, message.from.id)
api.unban_chat_member(message.chat.id, message.from.id)
elseif f.action == 'mute' then
api.delete_message(message.chat.id, message.message_id)
api.restrict_chat_member(message.chat.id, message.from.id, os.time() + 3600, {
can_send_messages = false
})
end
return
end
end
end
return plugin
diff --git a/src/plugins/utility/commandstats.lua b/src/plugins/utility/commandstats.lua
index 31ffd24..16cc7d7 100644
--- a/src/plugins/utility/commandstats.lua
+++ b/src/plugins/utility/commandstats.lua
@@ -1,62 +1,51 @@
--[[
mattata v2.0 - Command Stats Plugin
Displays command usage statistics for the current chat.
]]
local plugin = {}
plugin.name = 'commandstats'
plugin.category = 'utility'
plugin.description = 'View command usage statistics for this chat'
plugin.commands = { 'commandstats', 'cstats' }
plugin.help = '/commandstats - View top 10 most used commands in this chat.\n/cstats reset - Reset command statistics (admin only).'
plugin.group_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- Handle reset
if input and input:lower() == 'reset' then
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'You need to be an admin to reset command statistics.')
end
- ctx.db.execute(
- 'DELETE FROM command_stats WHERE chat_id = $1',
- { message.chat.id }
- )
+ ctx.db.call('sp_reset_command_stats', { message.chat.id })
return api.send_message(message.chat.id, 'Command statistics have been reset for this chat.')
end
-- Query top 10 commands by usage
- local result = ctx.db.execute(
- [[SELECT command, SUM(use_count) AS total
- FROM command_stats
- WHERE chat_id = $1
- GROUP BY command
- ORDER BY total DESC
- LIMIT 10]],
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_top_commands', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No command statistics available for this chat yet.')
end
local lines = { '<b>Command Usage Statistics</b>', '' }
local total_usage = 0
for i, row in ipairs(result) do
local count = tonumber(row.total) or 0
total_usage = total_usage + count
table.insert(lines, string.format(
'%d. /%s - <code>%d</code> uses',
i, tools.escape_html(row.command), count
))
end
table.insert(lines, '')
table.insert(lines, string.format('<i>Total (top 10): %d command uses</i>', total_usage))
return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
end
return plugin
diff --git a/src/plugins/utility/info.lua b/src/plugins/utility/info.lua
index c5d58a5..6747104 100644
--- a/src/plugins/utility/info.lua
+++ b/src/plugins/utility/info.lua
@@ -1,37 +1,37 @@
--[[
mattata v2.0 - Info Plugin
System information (admin only).
]]
local plugin = {}
plugin.name = 'info'
plugin.category = 'utility'
plugin.description = 'View system information'
plugin.commands = { 'info' }
plugin.help = '/info - View system and bot statistics.'
plugin.global_admin_only = true
function plugin.on_message(api, message, ctx)
local loader = require('src.core.loader')
local lines = {
'<b>mattata v' .. ctx.config.VERSION .. '</b>',
'',
'Plugins loaded: <code>' .. loader.count() .. '</code>',
'Lua version: <code>' .. _VERSION .. '</code>',
'Uptime: <code>' .. os.date('!%H:%M:%S', os.clock()) .. '</code>'
}
-- Database stats
- local user_count = ctx.db.query('SELECT COUNT(*) AS count FROM users')
- local chat_count = ctx.db.query('SELECT COUNT(*) AS count FROM chats')
+ local user_count = ctx.db.call('sp_count_users', {})
+ local chat_count = ctx.db.call('sp_count_chats', {})
if user_count and user_count[1] then
table.insert(lines, 'Users tracked: <code>' .. user_count[1].count .. '</code>')
end
if chat_count and chat_count[1] then
table.insert(lines, 'Groups tracked: <code>' .. chat_count[1].count .. '</code>')
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
end
return plugin
diff --git a/src/plugins/utility/nick.lua b/src/plugins/utility/nick.lua
index eb77417..1f88a33 100644
--- a/src/plugins/utility/nick.lua
+++ b/src/plugins/utility/nick.lua
@@ -1,59 +1,50 @@
--[[
mattata v2.0 - Nickname Plugin
Set, view, and delete your nickname.
]]
local plugin = {}
plugin.name = 'nick'
plugin.category = 'utility'
plugin.description = 'Set a custom nickname'
plugin.commands = { 'nick', 'nickname', 'setnick', 'nn' }
plugin.help = '/nick <name> - Set your nickname.\n/nick - View your current nickname.\n/nick --delete - Remove your nickname.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- View current nickname
if not input or input == '' then
- local result = ctx.db.execute(
- 'SELECT nickname FROM users WHERE user_id = $1',
- { message.from.id }
- )
+ local result = ctx.db.call('sp_get_nickname', { message.from.id })
if result and result[1] and result[1].nickname then
return api.send_message(
message.chat.id,
string.format('Your nickname is: <b>%s</b>', tools.escape_html(result[1].nickname)),
'html'
)
end
return api.send_message(message.chat.id, 'You don\'t have a nickname set. Use /nick <name> to set one.')
end
-- Delete nickname
if input == '--delete' or input == '-d' then
- ctx.db.execute(
- 'UPDATE users SET nickname = NULL WHERE user_id = $1',
- { message.from.id }
- )
+ ctx.db.call('sp_clear_nickname', { message.from.id })
return api.send_message(message.chat.id, 'Your nickname has been removed.')
end
-- Validate length
if #input > 128 then
return api.send_message(message.chat.id, 'Nicknames must be 128 characters or fewer.')
end
-- Set nickname
- ctx.db.execute(
- 'UPDATE users SET nickname = $1 WHERE user_id = $2',
- { input, message.from.id }
- )
+ ctx.db.call('sp_set_nickname', { message.from.id, input })
return api.send_message(
message.chat.id,
string.format('Your nickname has been set to: <b>%s</b>', tools.escape_html(input)),
'html'
)
end
return plugin
diff --git a/src/plugins/utility/setloc.lua b/src/plugins/utility/setloc.lua
index d626631..24ac2d6 100644
--- a/src/plugins/utility/setloc.lua
+++ b/src/plugins/utility/setloc.lua
@@ -1,82 +1,73 @@
--[[
mattata v2.0 - Set Location Plugin
Geocodes an address and stores latitude/longitude for weather and time plugins.
]]
local plugin = {}
plugin.name = 'setloc'
plugin.category = 'utility'
plugin.description = 'Set your location for weather and time commands'
plugin.commands = { 'setloc', 'setlocation', 'location' }
plugin.help = '/setloc <address> - Set your location by providing an address or place name.'
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local https = require('ssl.https')
local json = require('dkjson')
local url = require('socket.url')
local input = message.args
if not input or input == '' then
-- Show current location
- local result = ctx.db.execute(
- 'SELECT latitude, longitude, address FROM user_locations WHERE user_id = $1',
- { message.from.id }
- )
+ local result = ctx.db.call('sp_get_user_location', { message.from.id })
if result and result[1] then
return api.send_message(
message.chat.id,
string.format(
'Your location is set to: <b>%s</b>\n(<code>%s, %s</code>)',
tools.escape_html(result[1].address or 'Unknown'),
result[1].latitude,
result[1].longitude
),
'html'
)
end
return api.send_message(message.chat.id, 'You haven\'t set a location yet. Use /setloc <address> to set one.')
end
-- Geocode via Nominatim
local encoded = url.escape(input)
local api_url = string.format(
'https://nominatim.openstreetmap.org/search?q=%s&format=json&limit=1&addressdetails=1',
encoded
)
local body, status = https.request(api_url)
if not body or status ~= 200 then
return api.send_message(message.chat.id, 'Failed to geocode that address. Please try again.')
end
local data = json.decode(body)
if not data or #data == 0 then
return api.send_message(message.chat.id, 'No results found for that address. Please try a different query.')
end
local result = data[1]
local lat = tonumber(result.lat)
local lng = tonumber(result.lon)
local address = result.display_name or input
-- Upsert into user_locations
- ctx.db.execute(
- [[INSERT INTO user_locations (user_id, latitude, longitude, address, updated_at)
- VALUES ($1, $2, $3, $4, NOW())
- ON CONFLICT (user_id) DO UPDATE
- SET latitude = $2, longitude = $3, address = $4, updated_at = NOW()]],
- { message.from.id, lat, lng, address }
- )
+ ctx.db.call('sp_upsert_user_location', { message.from.id, lat, lng, address })
return api.send_message(
message.chat.id,
string.format(
'Location set to: <b>%s</b>\n(<code>%s, %s</code>)',
tools.escape_html(address),
lat, lng
),
'html'
)
end
return plugin
diff --git a/src/plugins/utility/statistics.lua b/src/plugins/utility/statistics.lua
index a978c81..2a155e1 100644
--- a/src/plugins/utility/statistics.lua
+++ b/src/plugins/utility/statistics.lua
@@ -1,92 +1,73 @@
--[[
mattata v2.0 - Statistics Plugin
Displays message statistics for the current chat.
]]
local plugin = {}
plugin.name = 'statistics'
plugin.category = 'utility'
plugin.description = 'View message statistics for this chat'
plugin.commands = { 'statistics', 'stats', 'morestats' }
plugin.help = '/stats - View top 10 most active users in this chat.\n/morestats - View extended stats.\n/stats reset - Reset statistics (admin only).'
plugin.group_only = true
function plugin.on_message(api, message, ctx)
local tools = require('telegram-bot-lua.tools')
local input = message.args
-- Handle reset
if input and input:lower() == 'reset' then
if not ctx.is_admin and not ctx.is_global_admin then
return api.send_message(message.chat.id, 'You need to be an admin to reset statistics.')
end
- ctx.db.execute(
- 'DELETE FROM message_stats WHERE chat_id = $1',
- { message.chat.id }
- )
+ ctx.db.call('sp_reset_message_stats', { message.chat.id })
return api.send_message(message.chat.id, 'Message statistics have been reset for this chat.')
end
-- Query top 10 users by message count
- local result = ctx.db.execute(
- [[SELECT ms.user_id, SUM(ms.message_count) AS total,
- u.first_name, u.last_name, u.username
- FROM message_stats ms
- LEFT JOIN users u ON ms.user_id = u.user_id
- WHERE ms.chat_id = $1
- GROUP BY ms.user_id, u.first_name, u.last_name, u.username
- ORDER BY total DESC
- LIMIT 10]],
- { message.chat.id }
- )
+ local result = ctx.db.call('sp_get_top_users', { message.chat.id })
if not result or #result == 0 then
return api.send_message(message.chat.id, 'No message statistics available for this chat yet.')
end
local lines = { '<b>Message Statistics</b>', '' }
local total_messages = 0
for i, row in ipairs(result) do
local name = tools.escape_html(row.first_name or 'Unknown')
if row.last_name then
name = name .. ' ' .. tools.escape_html(row.last_name)
end
local count = tonumber(row.total) or 0
total_messages = total_messages + count
table.insert(lines, string.format(
'%d. %s - <code>%d</code> messages',
i, name, count
))
end
table.insert(lines, '')
table.insert(lines, string.format('<i>Total (top 10): %d messages</i>', total_messages))
-- Extended stats for /morestats
if message.command == 'morestats' then
- local total_result = ctx.db.execute(
- 'SELECT SUM(message_count) AS total FROM message_stats WHERE chat_id = $1',
- { message.chat.id }
- )
- local unique_result = ctx.db.execute(
- 'SELECT COUNT(DISTINCT user_id) AS total FROM message_stats WHERE chat_id = $1',
- { message.chat.id }
- )
+ local total_result = ctx.db.call('sp_get_total_messages', { message.chat.id })
+ local unique_result = ctx.db.call('sp_get_unique_users', { message.chat.id })
if total_result and total_result[1] then
table.insert(lines, string.format(
'<i>All-time total: %s messages</i>',
total_result[1].total or '0'
))
end
if unique_result and unique_result[1] then
table.insert(lines, string.format(
'<i>Unique users: %s</i>',
unique_result[1].total or '0'
))
end
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
end
return plugin
diff --git a/src/plugins/utility/time.lua b/src/plugins/utility/time.lua
index a0a4237..e0c8c81 100644
--- a/src/plugins/utility/time.lua
+++ b/src/plugins/utility/time.lua
@@ -1,166 +1,163 @@
--[[
mattata v2.0 - Time Plugin
Shows current time and date for a location.
Geocodes via Nominatim, then uses timeapi.io for timezone lookup.
Supports stored locations from setloc.
]]
local plugin = {}
plugin.name = 'time'
plugin.category = 'utility'
plugin.description = 'Get current time for a location'
plugin.commands = { 'time', 't', 'date', 'd' }
plugin.help = '/time [location] - Get the current time and date for a location. Uses your saved location if none is specified.'
local https = require('ssl.https')
local json = require('dkjson')
local url = require('socket.url')
local ltn12 = require('ltn12')
local tools = require('telegram-bot-lua.tools')
local function geocode(query)
local encoded = url.escape(query)
local request_url = 'https://nominatim.openstreetmap.org/search?q=' .. encoded .. '&format=json&limit=1&addressdetails=1'
local body = {}
local _, code = https.request({
url = request_url,
sink = ltn12.sink.table(body),
headers = {
['User-Agent'] = 'mattata-telegram-bot/2.0'
}
})
if code ~= 200 then
return nil, 'Geocoding request failed.'
end
local data = json.decode(table.concat(body))
if not data or #data == 0 then
return nil, 'Location not found. Please check the spelling and try again.'
end
return {
lat = tonumber(data[1].lat),
lon = tonumber(data[1].lon),
name = data[1].display_name
}
end
local function get_timezone(lat, lon)
-- Use timeapi.io to get timezone from coordinates
local request_url = string.format(
'https://timeapi.io/api/TimeZone/coordinate?latitude=%.6f&longitude=%.6f',
lat, lon
)
local body = {}
local _, code = https.request({
url = request_url,
sink = ltn12.sink.table(body),
headers = {
['User-Agent'] = 'mattata-telegram-bot/2.0'
}
})
if code ~= 200 then
return nil, 'Timezone lookup failed.'
end
local data = json.decode(table.concat(body))
if not data or not data.timeZone then
return nil, 'Could not determine timezone for this location.'
end
return data
end
local function format_day_suffix(day)
local d = tonumber(day)
if d == 1 or d == 21 or d == 31 then return 'st'
elseif d == 2 or d == 22 then return 'nd'
elseif d == 3 or d == 23 then return 'rd'
else return 'th'
end
end
function plugin.on_message(api, message, ctx)
local input = message.args
local lat, lon, location_name
if not input or input == '' then
-- Try stored location
- local result = ctx.db.execute(
- 'SELECT latitude, longitude, address FROM user_locations WHERE user_id = $1',
- { message.from.id }
- )
+ local result = ctx.db.call('sp_get_user_location', { message.from.id })
if result and result[1] then
lat = tonumber(result[1].latitude)
lon = tonumber(result[1].longitude)
location_name = result[1].address or string.format('%.4f, %.4f', lat, lon)
else
return api.send_message(
message.chat.id,
'Please specify a location or set your default with /setloc.\nUsage: <code>/time London</code>',
'html'
)
end
else
local geo, err = geocode(input)
if not geo then
return api.send_message(message.chat.id, err)
end
lat = geo.lat
lon = geo.lon
location_name = geo.name
end
local tz_data, err = get_timezone(lat, lon)
if not tz_data then
return api.send_message(message.chat.id, err)
end
local timezone = tz_data.timeZone or 'Unknown'
local current_time = tz_data.currentLocalTime or ''
local utc_offset = tz_data.currentUtcOffset and tz_data.currentUtcOffset.seconds or 0
local dst_active = tz_data.hasDayLightSaving and tz_data.isDayLightSavingActive
-- Parse the datetime string (format: "2024-01-15T14:30:00.0000000")
local year, month, day, hour, min, sec = current_time:match('(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)')
if not year then
return api.send_message(message.chat.id, 'Failed to parse time data from the API.')
end
local months = { 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December' }
local days_of_week = { 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' }
-- Calculate day of week using Tomohiko Sakamoto's algorithm
local y, m, d = tonumber(year), tonumber(month), tonumber(day)
local t_table = { 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 }
if m < 3 then y = y - 1 end
local dow = (y + math.floor(y / 4) - math.floor(y / 100) + math.floor(y / 400) + t_table[m] + d) % 7 + 1
local day_suffix = format_day_suffix(day)
local offset_hours = utc_offset / 3600
local offset_str
if offset_hours >= 0 then
offset_str = string.format('+%g', offset_hours)
else
offset_str = string.format('%g', offset_hours)
end
local lines = {
'<b>' .. tools.escape_html(location_name) .. '</b>',
'',
string.format('Time: <b>%s:%s:%s</b>', hour, min, sec),
string.format('Date: <b>%s, %d%s %s %s</b>',
days_of_week[dow],
tonumber(day), day_suffix,
months[tonumber(month)],
year
),
string.format('Timezone: <code>%s</code> (UTC%s)', tools.escape_html(timezone), offset_str)
}
if dst_active then
table.insert(lines, 'DST: Active')
end
return api.send_message(message.chat.id, table.concat(lines, '\n'), 'html')
end
return plugin
diff --git a/src/plugins/utility/weather.lua b/src/plugins/utility/weather.lua
index 9cbdd1a..2f87552 100644
--- a/src/plugins/utility/weather.lua
+++ b/src/plugins/utility/weather.lua
@@ -1,171 +1,168 @@
--[[
mattata v2.0 - Weather Plugin
Shows current weather for a location using Open-Meteo (no API key needed).
Geocodes via Nominatim (OpenStreetMap). Supports stored locations from setloc.
]]
local plugin = {}
plugin.name = 'weather'
plugin.category = 'utility'
plugin.description = 'Get current weather for a location'
plugin.commands = { 'weather', 'w' }
plugin.help = '/weather [location] - Get current weather for a location. If no location is given, your saved location is used (set with /setloc).'
local https = require('ssl.https')
local json = require('dkjson')
local url = require('socket.url')
local ltn12 = require('ltn12')
local tools = require('telegram-bot-lua.tools')
-- WMO weather codes to human-readable descriptions
local WMO_CODES = {
[0] = 'Clear sky',
[1] = 'Mainly clear',
[2] = 'Partly cloudy',
[3] = 'Overcast',
[45] = 'Foggy',
[48] = 'Depositing rime fog',
[51] = 'Light drizzle',
[53] = 'Moderate drizzle',
[55] = 'Dense drizzle',
[56] = 'Light freezing drizzle',
[57] = 'Dense freezing drizzle',
[61] = 'Slight rain',
[63] = 'Moderate rain',
[65] = 'Heavy rain',
[66] = 'Light freezing rain',
[67] = 'Heavy freezing rain',
[71] = 'Slight snowfall',
[73] = 'Moderate snowfall',
[75] = 'Heavy snowfall',
[77] = 'Snow grains',
[80] = 'Slight rain showers',
[81] = 'Moderate rain showers',
[82] = 'Violent rain showers',
[85] = 'Slight snow showers',
[86] = 'Heavy snow showers',
[95] = 'Thunderstorm',
[96] = 'Thunderstorm with slight hail',
[99] = 'Thunderstorm with heavy hail'
}
local function geocode(query)
local encoded = url.escape(query)
local request_url = 'https://nominatim.openstreetmap.org/search?q=' .. encoded .. '&format=json&limit=1&addressdetails=1'
local body = {}
local _, code = https.request({
url = request_url,
sink = ltn12.sink.table(body),
headers = {
['User-Agent'] = 'mattata-telegram-bot/2.0'
}
})
if code ~= 200 then
return nil, 'Geocoding request failed.'
end
local data = json.decode(table.concat(body))
if not data or #data == 0 then
return nil, 'Location not found. Please check the spelling and try again.'
end
return {
lat = tonumber(data[1].lat),
lon = tonumber(data[1].lon),
name = data[1].display_name
}
end
local function get_weather(lat, lon)
local request_url = string.format(
'https://api.open-meteo.com/v1/forecast?latitude=%.6f&longitude=%.6f'
.. '&current=temperature_2m,relative_humidity_2m,apparent_temperature'
.. ',weather_code,wind_speed_10m,wind_direction_10m'
.. '&temperature_unit=celsius&wind_speed_unit=kmh',
lat, lon
)
local body = {}
local _, code = https.request({
url = request_url,
sink = ltn12.sink.table(body)
})
if code ~= 200 then
return nil, 'Weather API request failed.'
end
local data = json.decode(table.concat(body))
if not data or not data.current then
return nil, 'Failed to parse weather data.'
end
return data.current
end
local function c_to_f(c)
return c * 9 / 5 + 32
end
local function wind_direction(degrees)
local dirs = { 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW' }
local idx = math.floor((degrees / 22.5) + 0.5) % 16 + 1
return dirs[idx]
end
function plugin.on_message(api, message, ctx)
local input = message.args
local lat, lon, location_name
if not input or input == '' then
-- Try stored location
- local result = ctx.db.execute(
- 'SELECT latitude, longitude, address FROM user_locations WHERE user_id = $1',
- { message.from.id }
- )
+ local result = ctx.db.call('sp_get_user_location', { message.from.id })
if result and result[1] then
lat = tonumber(result[1].latitude)
lon = tonumber(result[1].longitude)
location_name = result[1].address or string.format('%.4f, %.4f', lat, lon)
else
return api.send_message(
message.chat.id,
'Please specify a location or set your default with /setloc.\nUsage: <code>/weather London</code>',
'html'
)
end
else
local geo, err = geocode(input)
if not geo then
return api.send_message(message.chat.id, err)
end
lat = geo.lat
lon = geo.lon
location_name = geo.name
end
local weather, err = get_weather(lat, lon)
if not weather then
return api.send_message(message.chat.id, err)
end
local temp_c = weather.temperature_2m or 0
local feels_c = weather.apparent_temperature or 0
local humidity = weather.relative_humidity_2m or 0
local wind_speed = weather.wind_speed_10m or 0
local wind_dir = wind_direction(weather.wind_direction_10m or 0)
local conditions = WMO_CODES[weather.weather_code] or 'Unknown'
local output = string.format(
'<b>Weather for %s</b>\n\n'
.. 'Conditions: %s\n'
.. 'Temperature: <b>%.1f°C</b> / <b>%.1f°F</b>\n'
.. 'Feels like: %.1f°C / %.1f°F\n'
.. 'Humidity: %d%%\n'
.. 'Wind: %.1f km/h %s',
tools.escape_html(location_name),
conditions,
temp_c, c_to_f(temp_c),
feels_c, c_to_f(feels_c),
humidity,
wind_speed, wind_dir
)
return api.send_message(message.chat.id, output, 'html')
end
return plugin

File Metadata

Mime Type
text/x-diff
Expires
Thu, May 14, 9:30 PM (1 d, 8 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
63573
Default Alt Text
(311 KB)

Event Timeline