Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
version: '3.8'

services:
redis:
image: redis:7-alpine
container_name: redis_db
restart: always
command: redis-server --requirepass "Radmin"
ports:
- "6379:6379"
Comment on lines +8 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid hardcoded Redis credentials on a host-exposed port.

Radmin is committed and Redis is published on all interfaces; it also has to match REDIS_PASSWORD from src/redis/index.ts, or the app cannot authenticate. Use an env-substituted secret and bind locally for dev.

Suggested change
-    command: redis-server --requirepass "Radmin"
+    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}"]
     ports:
-      - "6379:6379"
+      - "127.0.0.1:6379:6379"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
command: redis-server --requirepass "Radmin"
ports:
- "6379:6379"
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}"]
ports:
- "127.0.0.1:6379:6379"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 8 - 10, The Redis service configuration is
hardcoding the password and exposing the port broadly; update the docker-compose
Redis service to use an environment-substituted secret that matches the
REDIS_PASSWORD used by src/redis/index.ts, and bind the published port to
localhost for development. Adjust the Redis service command and ports mapping so
credentials are not committed and the app can still authenticate consistently.

volumes:
- redis_data:/data

influxdb:
image: influxdb:2.7-alpine
container_name: influx_db
restart: always
ports:
- "8086:8086"
environment:
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=Iadmin
- DOCKER_INFLUXDB_INIT_PASSWORD=Iadmin1234567
- DOCKER_INFLUXDB_INIT_ORG=polinetwork
- DOCKER_INFLUXDB_INIT_BUCKET=telegram_bot
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=tu_token_secreto_de_32_caracteres_o_mas
Comment on lines +18 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Move InfluxDB admin credentials out of the compose file.

The admin password/token are committed and the service is exposed on all interfaces. Since telemetry depends on INFLUXDB_TOKEN, make these env-driven so local setup and app config cannot drift.

Suggested change
     ports:
-      - "8086:8086"
+      - "127.0.0.1:8086:8086"
     environment:
       - DOCKER_INFLUXDB_INIT_MODE=setup
-      - DOCKER_INFLUXDB_INIT_USERNAME=Iadmin
-      - DOCKER_INFLUXDB_INIT_PASSWORD=Iadmin1234567
+      - DOCKER_INFLUXDB_INIT_USERNAME=${INFLUXDB_INIT_USERNAME:?INFLUXDB_INIT_USERNAME must be set}
+      - DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_INIT_PASSWORD:?INFLUXDB_INIT_PASSWORD must be set}
       - DOCKER_INFLUXDB_INIT_ORG=polinetwork
       - DOCKER_INFLUXDB_INIT_BUCKET=telegram_bot
-      - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=tu_token_secreto_de_32_caracteres_o_mas
+      - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUXDB_TOKEN:?INFLUXDB_TOKEN must be set}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ports:
- "8086:8086"
environment:
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=Iadmin
- DOCKER_INFLUXDB_INIT_PASSWORD=Iadmin1234567
- DOCKER_INFLUXDB_INIT_ORG=polinetwork
- DOCKER_INFLUXDB_INIT_BUCKET=telegram_bot
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=tu_token_secreto_de_32_caracteres_o_mas
ports:
- "127.0.0.1:8086:8086"
environment:
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=${INFLUXDB_INIT_USERNAME:?INFLUXDB_INIT_USERNAME must be set}
- DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_INIT_PASSWORD:?INFLUXDB_INIT_PASSWORD must be set}
- DOCKER_INFLUXDB_INIT_ORG=polinetwork
- DOCKER_INFLUXDB_INIT_BUCKET=telegram_bot
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUXDB_TOKEN:?INFLUXDB_TOKEN must be set}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 18 - 26, The InfluxDB service configuration
currently hardcodes admin credentials in the compose setup and exposes the port
broadly, so update the docker-compose service to read sensitive values from
environment variables or an env file instead of embedding them directly. Use the
existing InfluxDB init settings in the service definition to map
`DOCKER_INFLUXDB_INIT_*` values from env-driven inputs, and ensure the app’s
`INFLUXDB_TOKEN` stays the single source of truth for telemetry auth to avoid
config drift. Identify the changes in the InfluxDB service block by its
`DOCKER_INFLUXDB_INIT_*` environment entries and `ports` section.

volumes:
- influxdb_data:/var/lib/influxdb2

volumes:
redis_data:
influxdb_data:
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"main": "./dist/bot.js",
"type": "module",
"scripts": {
"start": "NODE_ENV=production node dist/bot.js",
"start": "cross-env NODE_ENV=production node dist/bot.js",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether runtime scripts depend on dev-only binaries.
node - <<'NODE'
const pkg = require('./package.json')
const startUsesCrossEnv = /\bcross-env\b/.test(pkg.scripts?.start || '')
const inDeps = Boolean(pkg.dependencies?.['cross-env'])
const inDevDeps = Boolean(pkg.devDependencies?.['cross-env'])

console.log({ startUsesCrossEnv, inDeps, inDevDeps })
if (startUsesCrossEnv && !inDeps) {
  process.exitCode = 1
}
NODE

Repository: PoliNetworkOrg/telegram

Length of output: 221


Move cross-env into dependencies or remove it from start. npm ci --omit=dev won’t install the binary, so production startup will fail. (package.json:9)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 9, The start script currently depends on cross-env, but
it may not be available in production installs. Update the package.json scripts
or dependency list so start no longer relies on a dev-only binary: either move
cross-env into dependencies or remove it from the start command and use a plain
NODE_ENV=production launch. Make the change in the start script entry so
production startup works after npm ci --omit=dev.

"build": "tsup",
"dev:raw": "NODE_ENV=development tsx watch --clear-screen=false --env-file=.env src/bot.ts",
"dev:raw": "cross-env NODE_ENV=development tsx watch --clear-screen=false --env-file=.env src/bot.ts",
"dev": "pnpm run dev:raw | pino-pretty",
"test": "vitest run",
"typecheck": "tsgo --noEmit",
Expand All @@ -23,6 +23,7 @@
"@trpc/server": "11.5.1",
"@types/node": "^24.12.2",
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"cross-env": "^7.0.3",
"pino-pretty": "^13.0.0",
"ts-node": "^10.9.2",
"tsup": "^8.4.0",
Expand All @@ -38,7 +39,7 @@
"@grammyjs/parse-mode": "^1.11.1",
"@grammyjs/runner": "^2.0.3",
"@influxdata/influxdb-client": "^1.35.0",
"@polinetwork/backend": "^0.15.18",
"@polinetwork/backend": "file:../backend/package",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether the local file dependency is resolvable in this checkout.
node - <<'NODE'
const fs = require('fs')
const path = require('path')
const pkg = require('./package.json')
const spec = pkg.dependencies?.['`@polinetwork/backend`']

console.log({ spec })
if (spec?.startsWith('file:')) {
  const target = path.resolve(process.cwd(), spec.slice('file:'.length))
  console.log({ target, exists: fs.existsSync(target) })
  if (!fs.existsSync(target)) process.exitCode = 1
}
NODE

Repository: PoliNetworkOrg/telegram

Length of output: 257


Avoid the sibling-local backend dependency. file:../backend/package only works when that neighboring path exists, so fresh installs in CI or published releases will fail to resolve @polinetwork/backend. Use a workspace dependency or a published semver version instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 42, The package manifest currently depends on a
sibling-local backend path, which breaks installs when that neighboring
directory is absent. Update the dependency for `@polinetwork/backend` in
package.json to use a workspace reference or a published semver version instead
of the file:../backend/package path so fresh CI and release installs can resolve
it reliably.

"@socket.io/devalue-parser": "^0.1.0",
"@t3-oss/env-core": "^0.13.4",
"@trpc/client": "^11.5.1",
Expand Down
22 changes: 17 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/commands/moderation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import { banAll } from "./banall"
import { del } from "./del"
import { kick } from "./kick"
import { mute } from "./mute"
import { warn } from "./warn"

export const moderation = new CommandsCollection<Role>("Moderation").withCollection(ban, banAll, del, kick, mute)
export const moderation = new CommandsCollection<Role>("Moderation").withCollection(ban, banAll, del, kick, mute, warn)
261 changes: 261 additions & 0 deletions src/commands/moderation/warn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { CommandsCollection } from "@/lib/managed-commands"
import { logger } from "@/logger"
import { modules } from "@/modules"
import { Moderation } from "@/modules/moderation"
import { fmt } from "@/utils/format"
import { ephemeral } from "@/utils/messages"
import { numberOrString, tgnumber, type MaybePromise, type PartialMessage, type Role } from "@/utils/types"
import { getOverloadUser, resolveUser } from "@/utils/users"
import { api } from "@/backend"


export type Warning = {
id: number;
targetId: number;
adminId: number;
groupId: number;
reason: string | null;
isExpired: boolean;
deletedAt: Date | null;
createdAt: Date;
group: {
title: string;
inviteLink: string | null;
} | null;
admin: {
id: number;
firstName: string;
lastName?: string;
username?: string;
isBot: boolean;
langCode?: string;
} | null;
target: {
id: number;
firstName: string;
lastName?: string;
username?: string;
isBot: boolean;
langCode?: string;
} | null;
};

async function ephemeralInGroup(ctx: { chat: { type: string } }, message: MaybePromise<PartialMessage>) {
if (ctx.chat.type === "private") {
await message
} else {
void ephemeral(message)
}
}

export const warn = new CommandsCollection<Role>("Warning")
.createCommand({
trigger: "warn",
args: [
{
key: "reasonOrUser",
optional: true,
description:
"If the message is a reply, this argument is the reason. Otherwise, it's the username or user id of the user to warn",
type: numberOrString,
},
{
key: "reason",
optional: true,
description: "Optional reason to warn the user",
},
],
description: "Warn a user from a group",
scope: "both",
permissions: {
allowedRoles: ["owner", "direttivo"],
excludedRoles: ["creator"],
allowGroupAdmins: true,
},
handler: async ({ args, context, repliedTo }) => {
Comment on lines +68 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Restrict /warn to groups, or require an explicit target group.

With scope: "both", this can run in DMs, but the mutation always stores groupId: context.chat.id. In a private chat that records the DM as the warning’s group and can still drive the later penalty logic, which breaks the group-scoped warning model from the PR objective.

Also applies to: 90-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/moderation/warn.ts` around lines 68 - 75, The /warn command
currently allows DM execution via scope: "both", but the handler always persists
groupId from context.chat.id, so warnings in private chats get tied to the DM
instead of a real group. Update the moderation/warn.ts command definition and
handler so warn only runs in group chats or requires an explicit target group
before calling the warn mutation, using the existing handler, context, and
repliedTo flow to enforce a valid group-scoped groupId.

const userOverload = await getOverloadUser(context, repliedTo ?? null, args.reasonOrUser, args.reason)
if (userOverload.isErr()) {
await ephemeralInGroup(context, context.reply(
repliedTo
? fmt(({ n }) => n`There was an error`)
: fmt(({ n }) => n`Target user not found, please try replying to their message`)
))
logger.error({ args, repliedTo }, `WARN: ${userOverload.error}`)
return
}

const { user, reason } = userOverload.value

try {
const { error } = await api.tg.warnings.create.mutate({
targetId: user.id,
adminId: context.from.id,
groupId: context.chat.id,
reason
})

if (error) {
await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`There was an error: ${error}`)))
return
}
} catch (error) {
logger.error({ error }, "Failed to warn user")
await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)))
return
}

const extraLines: string[] = []

if (context.chat.type === "group" || context.chat.type === "supergroup") {
try {
const [groupCount] = await api.tg.warnings.getActiveCountInGroup.query({
targetId: user.id,
groupId: context.chat.id,
})
if (groupCount.count >= 3) {
const kickRes = await Moderation.kick(user, context.chat, context.from, [], "Auto-kick: 3 warnings in this group")
if (kickRes.isOk()) {
extraLines.push("👢 Auto-kicked (3 warnings in this group)")
}
}
} catch (error) {
logger.error({ error, targetId: user.id, groupId: context.chat.id }, "Auto-kick check failed after warning")
}
}

try {
const [totalCount] = await api.tg.warnings.getTotalActiveCount.query({
targetId: user.id,
})
if (totalCount.count >= 4) {
await modules.get("tgLogger").banAll(user, context.from, "BAN", "Auto-ban all: 4 total warnings")
extraLines.push("🚫 Auto-ban all initiated (4 total warnings)")
Comment thread
itasimo marked this conversation as resolved.
Comment thread
itasimo marked this conversation as resolved.
}
} catch (error) {
logger.error({ error, targetId: user.id }, "Auto-ban all check failed after warning")
}

await ephemeralInGroup(context, context.reply(
fmt(({ b, n, i }) => [
b`⚠️ User has been warned!`,
n`${b`Target:`} ${user.username ? `@${user.username}` : user.first_name}`,
...(reason ? [n`${b`Reason:`} ${i`${reason}`}`] : []),
...(extraLines.length > 0 ? ["", ...extraLines.map(l => n`${l}`)] : []),
], {
sep: "\n",
})
))
}
})
.createCommand({
trigger: "unwarn",
args: [{ key: "warnId", type: tgnumber, description: "Warning ID to remove (use /warns to get the ID)" }],
description: "Remove a warning by its ID",
scope: "both",
permissions: {
allowedRoles: ["owner", "direttivo"],
excludedRoles: ["creator"],
allowGroupAdmins: true,
},
handler: async ({ args, context }) => {
const warnId = Number(args.warnId)

if (Number.isNaN(warnId)) {
await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`Invalid Warning ID. Use /warns to get the correct numeric ID.`)))
return
}

try {
const res = await api.tg.warnings.deleteById.mutate({
id: warnId
})
Comment on lines +168 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

/unwarn needs group-scoped authorization, not just a global warning ID.

This mutation only sends { id: warnId }, so the backend has no group context to verify that the warning belongs to the current chat. Any admin who can run /unwarn can remove warnings from another group by guessing IDs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/moderation/warn.ts` around lines 168 - 171, The /unwarn flow in
warn.ts only deletes by warnId, which allows cross-group deletion without chat
context. Update the moderation command handler around the deleteById mutate call
to also pass the current group/chat identifier, and make sure the backend
authorization path in the deleteById mutation verifies the warning belongs to
that chat before deleting. Use the existing warnId handling in this command plus
the current chat/group context available in the moderation command to scope the
request.


if (res?.deleted === false) {
await ephemeralInGroup(context, context.reply(
fmt(({ n }) => n`That warning was already removed, expired or doesn't exist.`)
))
return
}

await ephemeralInGroup(context, context.reply(
fmt(({ b, n }) => [
b`✅ Warning removed!`,
n`Warning with ID ${b`#${warnId}`} has been lifted.`
], {
sep: "\n",
})
))
} catch (error) {
logger.error({ warnId, error }, "Failed to delete warning")
await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)))
}
},
})
.createCommand({
trigger: "warns",
description: "Get the warnings of a user",
scope: "private",
args: [{ key: "username", type: numberOrString, description: "Username (or user id) to get the warnings of" }],
permissions: {
allowedRoles: ["owner", "direttivo"],
excludedRoles: ["creator"],
},
handler: async ({ args, context }) => {

const user = await resolveUser(args.username, context)
if (!user) return

try {
const warns: Warning[] = await api.tg.warnings.getByTarget.query({
targetId: user.id,
})

if (warns.length === 0) {
return void await context.reply(
fmt(({ n }) => n`${user.username || user.first_name} has clean records (0 warnings).`)
)
}

await context.reply(
fmt(({ b, n, i, code }) => {
const elements = [
b`⚠️ Warning history for @${user.username || user.first_name}:`,
`Total Warnings: ${warns.length}`,
"",
];

warns.forEach((warn, index) => {
const date = new Date(warn.createdAt).toLocaleDateString();
const status = warn.isExpired ? "🟢 [Expired]" : warn.deletedAt ? "🗑️ [Removed]" : "🔴 [Active]";

const warningId = warn.id
const groupName = warn.group?.title || `Group ID: ${warn.groupId}`;
const adminName = warn.admin?.firstName
? `@${warn.admin.username || warn.admin.firstName}`
: `ID: ${warn.adminId}`;

elements.push(
`${index + 1}. ${status} - ${date}`,
n` 🆔 ${b`Warning ID:`} ${code`${warningId}`}`,
n` 👥 ${b`Group:`} ${groupName}`,
n` 👮‍♂️ ${b`Admin:`} ${adminName}`,
n` 📝 ${b`Reason:`} ${warn.reason ? i`${warn.reason}` : "No reason provided"}`
)

if (index < warns.length - 1) {
elements.push("");
}
});

return elements;
}, {
sep: "\n",
})
)
} catch (error) {
logger.error({ error }, "Failed to fetch warnings")
await context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`))
}
},
})

Loading
Loading