init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,7 @@
|
||||
PORT=3780
|
||||
MATRIX_HOMESERVER_URL=https://matrix.example.org
|
||||
MATRIX_USERNAME=botuser
|
||||
MATRIX_PASSWORD=secret
|
||||
ACCESS_TOKEN=bot7835739915:AAGcKWlVKR123
|
||||
# MATRIX_TOKEN_CACHE_PATH=/var/lib/matrix-sender/token.json
|
||||
# MATRIX_TOKEN_CACHE_TTL_DAYS=364
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.matrix-sender-token.json
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
COPY package.json ./
|
||||
RUN pnpm install
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
COPY package.json ./
|
||||
RUN pnpm install --prod
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 3780
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "matrix-sender",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Telegram-совместимый HTTP-вебхук → сообщение в комнату Matrix (Synapse)",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.17.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
Generated
+1088
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import path from 'node:path';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
loadEnv();
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().int().positive().default(3780),
|
||||
MATRIX_HOMESERVER_URL: z.string().url(),
|
||||
MATRIX_USERNAME: z.string().min(1),
|
||||
MATRIX_PASSWORD: z.string().min(1),
|
||||
/** Сегмент пути `/ACCESS_TOKEN/sendMessage` (как `bot123:AA…` у Telegram или длинный hex). */
|
||||
ACCESS_TOKEN: z.string().min(1),
|
||||
/** Путь к JSON с access_token; по умолчанию `.matrix-sender-token.json` в cwd. */
|
||||
MATRIX_TOKEN_CACHE_PATH: z.string().min(1).optional(),
|
||||
/** Сколько дней считать токен валидным в кэше (Synapse ~год; по умолчанию 364). */
|
||||
MATRIX_TOKEN_CACHE_TTL_DAYS: z.coerce.number().int().positive().max(400).default(364),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export type ResolvedAppConfig = AppConfig & {
|
||||
resolvedMatrixTokenCachePath: string;
|
||||
matrixTokenCacheTtlMs: number;
|
||||
};
|
||||
|
||||
export function loadConfig(): ResolvedAppConfig {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.flatten().fieldErrors;
|
||||
throw new Error(`Некорректные переменные окружения: ${JSON.stringify(msg)}`);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const resolvedMatrixTokenCachePath =
|
||||
data.MATRIX_TOKEN_CACHE_PATH ?? path.join(process.cwd(), '.matrix-sender-token.json');
|
||||
const matrixTokenCacheTtlMs = data.MATRIX_TOKEN_CACHE_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
return { ...data, resolvedMatrixTokenCachePath, matrixTokenCacheTtlMs };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import { loadConfig } from './config';
|
||||
import { MatrixClient } from './matrix-client';
|
||||
import { registerTelegramCompatRoutes } from './telegram-routes';
|
||||
|
||||
const cfg = loadConfig();
|
||||
const matrix = new MatrixClient(cfg.MATRIX_HOMESERVER_URL, {
|
||||
tokenCachePath: cfg.resolvedMatrixTokenCachePath,
|
||||
tokenCacheTtlMs: cfg.matrixTokenCacheTtlMs,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
registerTelegramCompatRoutes(app, cfg, matrix);
|
||||
|
||||
app.listen(cfg.PORT, () => {
|
||||
console.log(`matrix-sender слушает порт ${cfg.PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import {
|
||||
clearMatrixTokenFile,
|
||||
readMatrixTokenFile,
|
||||
writeMatrixTokenFileAtomic,
|
||||
type MatrixTokenFilePayload,
|
||||
} from './matrix-token-file';
|
||||
|
||||
interface MatrixLoginResponse {
|
||||
user_id: string;
|
||||
access_token: string;
|
||||
device_id: string;
|
||||
home_server: string;
|
||||
}
|
||||
|
||||
interface MatrixSendEventResponse {
|
||||
event_id: string;
|
||||
}
|
||||
|
||||
function normalizeHomeserver(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function matrixErrDetail(err: unknown): string {
|
||||
if (!axios.isAxiosError(err)) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
const st = err.response?.status;
|
||||
const data = err.response?.data;
|
||||
if (st === undefined) {
|
||||
return err.message || 'сеть / нет ответа';
|
||||
}
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
const o = data as Record<string, unknown>;
|
||||
const errcode = typeof o.errcode === 'string' ? o.errcode : '';
|
||||
const error = typeof o.error === 'string' ? o.error : '';
|
||||
const human = [errcode, error].filter(Boolean).join(' — ');
|
||||
if (human) {
|
||||
return `HTTP ${String(st)} (${human})`;
|
||||
}
|
||||
}
|
||||
if (data !== undefined) {
|
||||
try {
|
||||
return `HTTP ${String(st)} (${JSON.stringify(data)})`;
|
||||
} catch {
|
||||
return `HTTP ${String(st)}`;
|
||||
}
|
||||
}
|
||||
return `HTTP ${String(st)}`;
|
||||
}
|
||||
|
||||
export interface MatrixClientOptions {
|
||||
/** Файл JSON с access_token (по умолчанию в cwd `.matrix-sender-token.json`). */
|
||||
tokenCachePath: string;
|
||||
/** Срок хранения токена после логина (Synapse часто ~1 год; по умолчанию 364 дня). */
|
||||
tokenCacheTtlMs: number;
|
||||
}
|
||||
|
||||
/** Клиент Synapse по образцу MatrixApiService (controller chatcoop). */
|
||||
export class MatrixClient {
|
||||
private readonly http: AxiosInstance;
|
||||
private readonly homeserverNorm: string;
|
||||
private cachedToken: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
private cacheUsername: string | null = null;
|
||||
|
||||
constructor(
|
||||
homeserverUrl: string,
|
||||
private readonly opts: MatrixClientOptions
|
||||
) {
|
||||
this.homeserverNorm = normalizeHomeserver(homeserverUrl);
|
||||
this.http = axios.create({
|
||||
baseURL: this.homeserverNorm,
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async loginWithPassword(username: string, password: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.http.post<MatrixLoginResponse>('/_matrix/client/r0/login', {
|
||||
type: 'm.login.password',
|
||||
user: username,
|
||||
password,
|
||||
});
|
||||
return response.data.access_token;
|
||||
} catch (err: unknown) {
|
||||
throw new Error(`Вход в Matrix не удался — ${matrixErrDetail(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAccessToken(username: string, password: string): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.cachedToken &&
|
||||
now < this.tokenExpiresAt &&
|
||||
this.cacheUsername === username &&
|
||||
this.tokenExpiresAt > 0
|
||||
) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
const fromDisk = await readMatrixTokenFile(this.opts.tokenCachePath);
|
||||
if (
|
||||
fromDisk &&
|
||||
normalizeHomeserver(fromDisk.homeserver_url) === this.homeserverNorm &&
|
||||
fromDisk.username === username &&
|
||||
fromDisk.expires_at_ms > now &&
|
||||
fromDisk.access_token.length > 0
|
||||
) {
|
||||
this.cachedToken = fromDisk.access_token;
|
||||
this.tokenExpiresAt = fromDisk.expires_at_ms;
|
||||
this.cacheUsername = username;
|
||||
return fromDisk.access_token;
|
||||
}
|
||||
|
||||
const token = await this.loginWithPassword(username, password);
|
||||
const expiresAt = now + this.opts.tokenCacheTtlMs;
|
||||
const payload: MatrixTokenFilePayload = {
|
||||
homeserver_url: this.homeserverNorm,
|
||||
username,
|
||||
access_token: token,
|
||||
expires_at_ms: expiresAt,
|
||||
};
|
||||
try {
|
||||
await writeMatrixTokenFileAtomic(this.opts.tokenCachePath, payload);
|
||||
} catch {
|
||||
/* файл кэша не обязателен для работы */
|
||||
}
|
||||
this.cachedToken = token;
|
||||
this.tokenExpiresAt = expiresAt;
|
||||
this.cacheUsername = username;
|
||||
return token;
|
||||
}
|
||||
|
||||
async sendTextToRoom(roomId: string, body: string, username: string, password: string): Promise<string> {
|
||||
const accessToken = await this.ensureAccessToken(username, password);
|
||||
const txnId = `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
try {
|
||||
const response = await this.http.put<MatrixSendEventResponse>(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
||||
{ msgtype: 'm.text', body },
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const eventId = response.data?.event_id;
|
||||
if (!eventId) {
|
||||
throw new Error('Matrix не вернул event_id');
|
||||
}
|
||||
return eventId;
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 401) {
|
||||
this.cachedToken = null;
|
||||
this.tokenExpiresAt = 0;
|
||||
this.cacheUsername = null;
|
||||
await clearMatrixTokenFile(this.opts.tokenCachePath).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
const detail = matrixErrDetail(err);
|
||||
throw new Error(`Логин в Matrix прошёл, отправка в комнату «${roomId}» не удалась — ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendTextToRoomRetry(roomId: string, body: string, username: string, password: string): Promise<string> {
|
||||
try {
|
||||
return await this.sendTextToRoom(roomId, body, username, password);
|
||||
} catch (first: unknown) {
|
||||
if (!axios.isAxiosError(first) || first.response?.status !== 401) {
|
||||
throw first instanceof Error ? first : new Error(String(first));
|
||||
}
|
||||
return await this.sendTextToRoom(roomId, body, username, password);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface MatrixTokenFilePayload {
|
||||
homeserver_url: string;
|
||||
username: string;
|
||||
access_token: string;
|
||||
expires_at_ms: number;
|
||||
}
|
||||
|
||||
function isPayload(x: unknown): x is MatrixTokenFilePayload {
|
||||
if (!x || typeof x !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const o = x as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.homeserver_url === 'string' &&
|
||||
typeof o.username === 'string' &&
|
||||
typeof o.access_token === 'string' &&
|
||||
typeof o.expires_at_ms === 'number' &&
|
||||
Number.isFinite(o.expires_at_ms)
|
||||
);
|
||||
}
|
||||
|
||||
export async function readMatrixTokenFile(filePath: string): Promise<MatrixTokenFilePayload | null> {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isPayload(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err: unknown) {
|
||||
const code = err && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : '';
|
||||
if (code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMatrixTokenFileAtomic(filePath: string, payload: MatrixTokenFilePayload): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tmp, `${JSON.stringify(payload)}\n`, 'utf8');
|
||||
await rename(tmp, filePath);
|
||||
}
|
||||
|
||||
export async function clearMatrixTokenFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (err: unknown) {
|
||||
const code = err && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : '';
|
||||
if (code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Express, Request, Response } from 'express';
|
||||
import type { AppConfig } from './config';
|
||||
import type { MatrixClient } from './matrix-client';
|
||||
|
||||
/** Первый сегмент пути до `/sendMessage` (Telegram `bot…:…` или любой секрет, напр. hex). */
|
||||
const POST_SEND_MESSAGE_PATH = /^\/([^/]+)\/sendMessage\/?$/;
|
||||
|
||||
function bodyString(v: unknown): string | null {
|
||||
if (typeof v === 'string') {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return String(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSendBody(req: Request): { chatId: string; text: string } | null {
|
||||
const chatRaw = bodyString(req.body?.chat_id);
|
||||
const textRaw = bodyString(req.body?.text);
|
||||
if (chatRaw === null || textRaw === null) {
|
||||
return null;
|
||||
}
|
||||
const chatId = chatRaw.trim();
|
||||
const text = textRaw;
|
||||
if (!chatId || !text) {
|
||||
return null;
|
||||
}
|
||||
return { chatId, text };
|
||||
}
|
||||
|
||||
function telegramOk(eventId: string, chatId: string, text: string) {
|
||||
return {
|
||||
ok: true as const,
|
||||
result: {
|
||||
message_id: 1,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: { id: Number(chatId) || chatId, type: 'group' as const },
|
||||
text,
|
||||
matrix_event_id: eventId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function telegramErr(description: string, status = 400) {
|
||||
return { ok: false as const, error_code: status, description };
|
||||
}
|
||||
|
||||
export function registerTelegramCompatRoutes(
|
||||
app: Express,
|
||||
cfg: AppConfig,
|
||||
matrix: MatrixClient
|
||||
): void {
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.status(200).type('text/plain').send('ok');
|
||||
});
|
||||
|
||||
app.post(POST_SEND_MESSAGE_PATH, async (req: Request, res: Response) => {
|
||||
const m = req.path.match(POST_SEND_MESSAGE_PATH);
|
||||
const pathToken = m?.[1];
|
||||
if (!pathToken || pathToken !== cfg.ACCESS_TOKEN) {
|
||||
res.status(401).json(telegramErr('Unauthorized: неверный токен в пути (должен совпадать с ACCESS_TOKEN)', 401));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseSendBody(req);
|
||||
if (!parsed) {
|
||||
res
|
||||
.status(400)
|
||||
.json(telegramErr('Bad Request: chat_id (id комнаты Matrix) и text (непустые строки) обязательны', 400));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventId = await matrix.sendTextToRoomRetry(
|
||||
parsed.chatId,
|
||||
parsed.text,
|
||||
cfg.MATRIX_USERNAME,
|
||||
cfg.MATRIX_PASSWORD
|
||||
);
|
||||
res.status(200).json(telegramOk(eventId, parsed.chatId, parsed.text));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Matrix request failed';
|
||||
res.status(502).json(telegramErr(`Bad Gateway: ${message}`, 502));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user