Mostly working. Image actually being embedded now. - Inject `ChatManager` dependency into `TalkService`. - Refactor `uploadImage` to generate timestamped filenames (`talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext`) and fix directory creation logic for Nextcloud 33 compatibility. - Update file sharing logic to use `TYPE_ROOM` shares resolved by `fileId` and create a separate `TYPE_LINK` share for public previews. - Switch message posting to use `ChatManager::addSystemMessage` for `file_shared` events. - Send text message via HTTP API as a fallback to ensure participant records and moderation checks. - Update logging to include debug information for rich objects and share IDs.
1831 lines
77 KiB
PHP
1831 lines
77 KiB
PHP
<?php
|
|
|
|
namespace OCA\Ncbotwebhooks\Service;
|
|
|
|
use OCP\AppFramework\Http;
|
|
use OCP\Files\File;
|
|
use OCP\Files\Folder;
|
|
use OCP\Files\IRootFolder;
|
|
use OCP\Http\Client\IClientService;
|
|
use OCP\Security\ICrypto;
|
|
use OCP\IConfig;
|
|
use OCP\IDBConnection;
|
|
use OCP\IRequest;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUserManager;
|
|
use OCP\Share\IManager as IShareManager;
|
|
use OCP\Share\IShare;
|
|
use OCA\Talk\Chat\ChatManager;
|
|
use OCA\Talk\Manager as TalkManager;
|
|
use OCA\Talk\Model\Attendee;
|
|
use OCA\Talk\Model\AttendeeMapper;
|
|
use OCA\Talk\Service\ParticipantService;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class TalkService {
|
|
private const APP_ID = 'nc_bot_webhooks';
|
|
private const IMAGES_DIR = 'nc_bot_webhooks-images';
|
|
|
|
private IDBConnection $db;
|
|
private IRootFolder $rootFolder;
|
|
private IRequest $request;
|
|
private IURLGenerator $urlGenerator;
|
|
private IUserManager $userManager;
|
|
private LoggerInterface $logger;
|
|
private IClientService $clientService;
|
|
private TalkManager $talkManager;
|
|
private ICrypto $crypto;
|
|
private IConfig $config;
|
|
private AttendeeMapper $attendeeMapper;
|
|
private IShareManager $shareManager;
|
|
private ParticipantService $participantService;
|
|
private ChatManager $chatManager;
|
|
|
|
public function __construct(
|
|
IClientService $clientService,
|
|
IConfig $config,
|
|
IDBConnection $db,
|
|
IRootFolder $rootFolder,
|
|
IRequest $request,
|
|
IURLGenerator $urlGenerator,
|
|
IUserManager $userManager,
|
|
LoggerInterface $logger,
|
|
TalkManager $talkManager,
|
|
ICrypto $crypto,
|
|
AttendeeMapper $attendeeMapper,
|
|
IShareManager $shareManager,
|
|
ParticipantService $participantService,
|
|
ChatManager $chatManager,
|
|
) {
|
|
$this->clientService = $clientService;
|
|
$this->config = $config;
|
|
$this->db = $db;
|
|
$this->rootFolder = $rootFolder;
|
|
$this->request = $request;
|
|
$this->urlGenerator = $urlGenerator;
|
|
$this->userManager = $userManager;
|
|
$this->logger = $logger;
|
|
$this->talkManager = $talkManager;
|
|
$this->crypto = $crypto;
|
|
$this->attendeeMapper = $attendeeMapper;
|
|
$this->shareManager = $shareManager;
|
|
$this->participantService = $participantService;
|
|
$this->chatManager = $chatManager;
|
|
}
|
|
|
|
/**
|
|
* Resolve Talk services from the global DI container.
|
|
* In NC33, \OC\AppFramework\App::getAppContainer() was removed.
|
|
* \OCP\Server::get() resolves the service via ServerContainer::getAppContainerForService()
|
|
* which looks up the target class's app namespace (e.g. OCA\Talk) and returns the correct service.
|
|
*/
|
|
private function resolveTalkService(string $class): object {
|
|
return \OCP\Server::get($class);
|
|
}
|
|
|
|
// ── Bot password ──────────────────────────────────────────────
|
|
|
|
public function getBotPassword(): ?string {
|
|
$encrypted = $this->config->getAppValue(self::APP_ID, 'bot_password', '');
|
|
if ($encrypted === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
return $this->crypto->decrypt($encrypted);
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a bot password by encrypting and decrypting it (round-trip test).
|
|
* Returns ['valid' => true] on success, or ['valid' => false, 'error' => '...'] on failure.
|
|
*/
|
|
public function validateBotPassword(string $password): array {
|
|
if ($password === '') {
|
|
return ['valid' => false, 'error' => 'Bot password cannot be empty.'];
|
|
}
|
|
|
|
try {
|
|
$encrypted = $this->crypto->encrypt($password);
|
|
$decrypted = $this->crypto->decrypt($encrypted);
|
|
if ($decrypted !== $password) {
|
|
return ['valid' => false, 'error' => 'Password encryption/decryption failed. The password may contain unsupported characters.'];
|
|
}
|
|
return ['valid' => true];
|
|
} catch (\Exception $e) {
|
|
return ['valid' => false, 'error' => 'Password encryption failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
public function setBotPassword(string $password): void {
|
|
$this->config->setAppValue(self::APP_ID, 'bot_password', $this->crypto->encrypt($password));
|
|
}
|
|
|
|
public function hasBotPassword(): bool {
|
|
return $this->getBotPassword() !== null;
|
|
}
|
|
|
|
public function getBotUser(): ?\OCP\IUser {
|
|
return $this->userManager->get('talk-bot');
|
|
}
|
|
|
|
/**
|
|
* Get the raw DB connection (for diagnostic use).
|
|
*/
|
|
public function getDbConnection(): IDBConnection {
|
|
return $this->db;
|
|
}
|
|
|
|
// ── Retention ─────────────────────────────────────────────────
|
|
|
|
public function getRetentionDays(): int {
|
|
return (int) $this->config->getAppValue(self::APP_ID, 'retention_days', '90');
|
|
}
|
|
|
|
public function setRetentionDays(int $days): void {
|
|
$this->config->setAppValue(self::APP_ID, 'retention_days', (string) $days);
|
|
}
|
|
|
|
// ── Base URL ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the server base URL for internal API calls.
|
|
* Uses trusted_domains as the base URL — the HTTP client blocks
|
|
* all localhost addresses, and in Docker deployments overwrite.cli.url
|
|
* often points to a localhost:port that is unreachable from within the container.
|
|
*/
|
|
public function getBaseUrl(): string {
|
|
// Prefer overwritehost (hostname override) over trusted_domains
|
|
$overwriteHost = $this->config->getSystemValueString('overwritehost', '');
|
|
if ($overwriteHost !== '') {
|
|
$proto = $this->config->getSystemValueString('overwriteproto', 'https');
|
|
return rtrim($proto . '://' . $overwriteHost, '/');
|
|
}
|
|
|
|
$overwritten = $this->config->getSystemValueString('overwritewebroot', '');
|
|
if ($overwritten !== '') {
|
|
// overwritewebroot may be a path — prepend current host/proto if needed
|
|
if (preg_match('#^https?://#', $overwritten)) {
|
|
return rtrim($overwritten, '/');
|
|
}
|
|
// It's a path — fall through to trusted_domains
|
|
}
|
|
|
|
// Prefer a public (non-loopback, non-private) trusted domain.
|
|
// In Docker/container deployments, trusted_domains[0] is often 127.0.0.1
|
|
// which is unreachable from within the container.
|
|
$trusted = $this->config->getSystemValue('trusted_domains', []);
|
|
foreach ($trusted as $domain) {
|
|
if ($domain === '') {
|
|
continue;
|
|
}
|
|
// Already has a scheme — trust it as-is
|
|
if (preg_match('#^https?://#', $domain)) {
|
|
return rtrim($domain, '/');
|
|
}
|
|
// Skip loopback and localhost
|
|
$lower = strtolower($domain);
|
|
if ($lower === 'localhost'
|
|
|| $lower === '127.0.0.1'
|
|
|| $lower === '::1'
|
|
) {
|
|
continue;
|
|
}
|
|
// Skip private IP ranges (10.x, 172.16-31.x, 192.168.x)
|
|
if (preg_match(
|
|
'/^(10\.\d{1,3}|172\.(1[6-9]|2\d|3[01])|192\.168)\./i',
|
|
$domain
|
|
)) {
|
|
continue;
|
|
}
|
|
return 'https://' . $domain;
|
|
}
|
|
|
|
// Fallback to first trusted domain (may be localhost; caller may fail)
|
|
if (!empty($trusted[0])) {
|
|
if (preg_match('#^https?://#', $trusted[0])) {
|
|
return rtrim($trusted[0], '/');
|
|
}
|
|
return 'https://' . $trusted[0];
|
|
}
|
|
|
|
// Last resort: try overwrite.cli.url
|
|
$cliUrl = $this->config->getSystemValueString('overwrite.cli.url', '');
|
|
if ($cliUrl !== '') {
|
|
return rtrim($cliUrl, '/');
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
// ── Rooms ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get configured rooms: room token → display name
|
|
*/
|
|
public function getRooms(): array {
|
|
$json = $this->config->getAppValue(self::APP_ID, 'rooms', '[]');
|
|
$rooms = json_decode($json, true);
|
|
return is_array($rooms) ? $rooms : [];
|
|
}
|
|
|
|
/**
|
|
* Save configured rooms: room token → display name
|
|
*/
|
|
public function setRooms(array $rooms): void {
|
|
$this->config->setAppValue(self::APP_ID, 'rooms', json_encode($rooms));
|
|
}
|
|
|
|
/**
|
|
* Get available Talk rooms via database query.
|
|
*
|
|
* Queries the Talk DB directly (bypasses the OCS API which does not
|
|
* accept app passwords for auth). Returns only public channels (type 1)
|
|
* — the only room type that makes sense for a Discord-style webhook
|
|
* channel picker. Returns rooms as [token => displayName] pairs.
|
|
*/
|
|
public function getAvailableTalkRooms(): array {
|
|
// Detect table names by querying PostgreSQL information_schema directly.
|
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
|
|
|
if ($roomTable === null) {
|
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
|
$this->logger->warning('NCbotwebhooks: Talk tables not found', [
|
|
'app' => self::APP_ID,
|
|
'sysPrefix' => $sysPrefix,
|
|
'talkPrefix' => $talkPrefix,
|
|
]);
|
|
return [];
|
|
}
|
|
|
|
// In NC33 the room display name is stored in the 'name' column
|
|
// for all room types. Exclude deleted (type 4), note-to-self (type 6),
|
|
// sample rooms, file share rooms (object_type = 'file'),
|
|
// and private DM rooms (name starts with '["').
|
|
try {
|
|
$sql = 'SELECT token, COALESCE(NULLIF(name, \'\'), token) as display_name
|
|
FROM "' . $roomTable . '"
|
|
WHERE type IN (1, 2, 3)
|
|
AND (object_type IS NULL OR object_type != \'sample\')
|
|
AND object_type != \'note_to_self\'
|
|
AND object_type != \'file\'
|
|
AND name NOT LIKE \'["%\'';
|
|
$result = $this->db->executeQuery($sql);
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: room name query failed, using token fallback', [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$sql = 'SELECT token, token as display_name
|
|
FROM "' . $roomTable . '"
|
|
WHERE type IN (1, 2, 3)
|
|
AND (object_type IS NULL OR object_type != \'sample\')
|
|
AND object_type != \'note_to_self\'
|
|
AND object_type != \'file\'
|
|
AND name NOT LIKE \'["%\'';
|
|
$result = $this->db->executeQuery($sql);
|
|
}
|
|
|
|
$this->logger->info('NCbotwebhooks: getAvailableTalkRooms', [
|
|
'app' => self::APP_ID,
|
|
'room_table' => $roomTable,
|
|
]);
|
|
|
|
try {
|
|
$rooms = [];
|
|
while ($row = $result->fetch()) {
|
|
$rooms[$row['token']] = $row['display_name'] !== '' ? $row['display_name'] : $row['token'];
|
|
}
|
|
$result->closeCursor();
|
|
|
|
$this->logger->info('NCbotwebhooks: found ' . count($rooms) . ' rooms', [
|
|
'app' => self::APP_ID,
|
|
'rooms' => array_keys($rooms),
|
|
]);
|
|
|
|
return $rooms;
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('NCbotwebhooks: room listing exception', [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
]);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debug helper: return all rooms with key columns for inspection.
|
|
*/
|
|
public function getAllTalkRoomsDebug(int $limit = 100): array {
|
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
|
if ($roomTable === null) {
|
|
return [];
|
|
}
|
|
$sql = 'SELECT id, token, type, readable_name, label, name, object_type, object_id
|
|
FROM "' . $roomTable . '"
|
|
ORDER BY id LIMIT ' . (int)$limit;
|
|
$result = $this->db->executeQuery($sql);
|
|
$rooms = [];
|
|
while ($row = $result->fetch()) {
|
|
$rooms[] = [
|
|
'id' => $row['id'],
|
|
'token' => $row['token'],
|
|
'type' => (int)$row['type'],
|
|
'readable_name' => $row['readable_name'] ?? null,
|
|
'label' => $row['label'] ?? null,
|
|
'name' => $row['name'] ?? null,
|
|
'object_type' => $row['object_type'] ?? null,
|
|
'object_id' => $row['object_id'] ?? null,
|
|
];
|
|
}
|
|
$result->closeCursor();
|
|
return $rooms;
|
|
}
|
|
|
|
/**
|
|
* Debug helper: count of rooms per type.
|
|
*/
|
|
public function getRoomTypeBreakdown(): array {
|
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
|
if ($roomTable === null) {
|
|
return [];
|
|
}
|
|
$sql = 'SELECT type, COUNT(*) as count
|
|
FROM "' . $roomTable . '"
|
|
GROUP BY type ORDER BY type';
|
|
$result = $this->db->executeQuery($sql);
|
|
$breakdown = [];
|
|
while ($row = $result->fetch()) {
|
|
$breakdown[(int)$row['type']] = (int)$row['count'];
|
|
}
|
|
$result->closeCursor();
|
|
return $breakdown;
|
|
}
|
|
|
|
/**
|
|
* Detect which Talk table name variant exists in the database.
|
|
*/
|
|
public function detectTalkTableFromCatalog(string $newName, string $oldName): ?string {
|
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
|
|
|
// Build candidate list ordered by likelihood
|
|
$candidates = [
|
|
$talkPrefix . $newName,
|
|
$sysPrefix . $newName,
|
|
$talkPrefix . $oldName,
|
|
$sysPrefix . $oldName,
|
|
$newName,
|
|
$oldName,
|
|
];
|
|
|
|
// Deduplicate preserving order
|
|
$seen = [];
|
|
$unique = [];
|
|
foreach ($candidates as $name) {
|
|
if (!isset($seen[$name])) {
|
|
$seen[$name] = true;
|
|
$unique[] = $name;
|
|
}
|
|
}
|
|
|
|
// Query information_schema to find which candidates actually exist as tables
|
|
try {
|
|
$result = $this->db->executeQuery(
|
|
'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\' AND table_name IN (' . implode(',', array_map(fn($n) => '\'' . $n . '\'', $unique)) . ')',
|
|
);
|
|
$found = [];
|
|
while ($row = $result->fetch()) {
|
|
$found[] = $row['table_name'];
|
|
}
|
|
$result->closeCursor();
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: information_schema query failed', [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// Try executing a lightweight query on each candidate to verify it's usable.
|
|
foreach ($unique as $table) {
|
|
try {
|
|
$testResult = $this->db->executeQuery(
|
|
'SELECT 1 FROM "' . $table . '" LIMIT 1',
|
|
);
|
|
$testResult->closeCursor();
|
|
return $table;
|
|
} catch (\Exception $e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Debug helper: get column names and types for a table.
|
|
*/
|
|
public function getTalkTableColumns(string $tableName): array {
|
|
$columns = $this->db->executeQuery(
|
|
'SELECT column_name, data_type, is_nullable
|
|
FROM information_schema.columns
|
|
WHERE table_name = ?
|
|
ORDER BY ordinal_position',
|
|
[$tableName],
|
|
);
|
|
$result = [];
|
|
while ($row = $columns->fetch()) {
|
|
$result[] = [
|
|
'name' => $row['column_name'],
|
|
'type' => $row['data_type'],
|
|
'nullable' => $row['is_nullable'],
|
|
];
|
|
}
|
|
$columns->closeCursor();
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Debug helper: get all column names from a table, then fetch sample rows.
|
|
*/
|
|
public function getTalkTableSample(string $tableName, int $limit): array {
|
|
// Get column names
|
|
$colResult = $this->db->executeQuery(
|
|
'SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = ? ORDER BY ordinal_position',
|
|
[$tableName],
|
|
);
|
|
$colNames = [];
|
|
while ($row = $colResult->fetch()) {
|
|
$colNames[] = $row['column_name'];
|
|
}
|
|
$colResult->closeCursor();
|
|
|
|
if (empty($colNames)) {
|
|
return [];
|
|
}
|
|
|
|
$colList = implode(', ', array_map(fn($c) => '"' . $c . '"', $colNames));
|
|
$rows = $this->db->executeQuery(
|
|
'SELECT ' . $colList . ' FROM "' . $tableName . '" ORDER BY id LIMIT ?',
|
|
[$limit],
|
|
);
|
|
|
|
$result = [];
|
|
while ($row = $rows->fetch()) {
|
|
$result[] = $row;
|
|
}
|
|
$rows->closeCursor();
|
|
return $result;
|
|
}
|
|
|
|
// ── Auth tokens ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get auth tokens for a room: room token → [token1, token2, ...]
|
|
*/
|
|
public function getAuthTokens(): array {
|
|
$json = $this->config->getAppValue(self::APP_ID, 'auth_tokens', '{}');
|
|
$tokens = json_decode($json, true);
|
|
return is_array($tokens) ? $tokens : [];
|
|
}
|
|
|
|
/**
|
|
* Save auth tokens for a room.
|
|
*/
|
|
public function setAuthTokens(array $tokens): void {
|
|
$this->config->setAppValue(self::APP_ID, 'auth_tokens', json_encode($tokens));
|
|
}
|
|
|
|
/**
|
|
* Validate auth token for a room.
|
|
*/
|
|
public function validateAuthToken(string $roomToken, string $authToken): bool {
|
|
$allTokens = $this->getAuthTokens();
|
|
if (!isset($allTokens[$roomToken]) || !is_array($allTokens[$roomToken])) {
|
|
return false;
|
|
}
|
|
return in_array($authToken, $allTokens[$roomToken], true);
|
|
}
|
|
|
|
/**
|
|
* Generate a new auth token for a room.
|
|
* Returns the new token.
|
|
*/
|
|
public function generateAuthToken(string $roomToken): string {
|
|
$token = bin2hex(random_bytes(24)); // 48-char hex token
|
|
$allTokens = $this->getAuthTokens();
|
|
if (!isset($allTokens[$roomToken])) {
|
|
$allTokens[$roomToken] = [];
|
|
}
|
|
$allTokens[$roomToken][] = $token;
|
|
$this->setAuthTokens($allTokens);
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* Revoke a token from a room.
|
|
*/
|
|
public function revokeAuthToken(string $roomToken, string $authToken): void {
|
|
$allTokens = $this->getAuthTokens();
|
|
if (!isset($allTokens[$roomToken]) || !is_array($allTokens[$roomToken])) {
|
|
return;
|
|
}
|
|
$allTokens[$roomToken] = array_values(array_filter(
|
|
$allTokens[$roomToken],
|
|
fn($t) => $t !== $authToken
|
|
));
|
|
if (empty($allTokens[$roomToken])) {
|
|
unset($allTokens[$roomToken]);
|
|
}
|
|
$this->setAuthTokens($allTokens);
|
|
}
|
|
|
|
// ── Payload mapping ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Map Discord webhook payload to Talk message text.
|
|
*/
|
|
/**
|
|
* Prepend a display name line to a message.
|
|
* Since Talk doesn't support per-message avatars, we embed the name in the message.
|
|
*/
|
|
public function prependDisplayName(string $displayName, string $message): string {
|
|
$nameLine = '🤖 **' . $displayName . '**';
|
|
if ($message === '') {
|
|
return $nameLine;
|
|
}
|
|
return $nameLine . "\n\n" . $message;
|
|
}
|
|
|
|
public function mapPayload(array $data): string {
|
|
$parts = [];
|
|
|
|
// Regular content
|
|
if (!empty($data['content'])) {
|
|
$parts[] = $data['content'];
|
|
}
|
|
|
|
// Embeds
|
|
if (!empty($data['embeds']) && is_array($data['embeds'])) {
|
|
foreach ($data['embeds'] as $embed) {
|
|
if (!is_array($embed)) {
|
|
continue;
|
|
}
|
|
if (!empty($embed['title'])) {
|
|
$parts[] = '**' . $embed['title'] . '**';
|
|
}
|
|
if (!empty($embed['description'])) {
|
|
$parts[] = $embed['description'];
|
|
}
|
|
if (!empty($embed['fields']) && is_array($embed['fields'])) {
|
|
foreach ($embed['fields'] as $field) {
|
|
if (is_array($field) && !empty($field['name'])) {
|
|
$fieldText = $field['name'] . ': ';
|
|
if (!empty($field['value'])) {
|
|
$fieldText .= $field['value'];
|
|
}
|
|
$parts[] = $fieldText;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode("\n\n", $parts);
|
|
}
|
|
|
|
/**
|
|
* Get sender name from payload or config default.
|
|
*/
|
|
public function getSenderName(array $data): string {
|
|
if (!empty($data['sender_name'])) {
|
|
return $data['sender_name'];
|
|
}
|
|
if (!empty($data['username'])) {
|
|
return $data['username'];
|
|
}
|
|
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
|
}
|
|
|
|
/**
|
|
* Get sender name default.
|
|
*/
|
|
public function getSenderNameDefault(): string {
|
|
return $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot');
|
|
}
|
|
|
|
/**
|
|
* Map Apprise JSON payload to our internal format.
|
|
*
|
|
* Apprise sends: { version, type, title, body, attachments }
|
|
* Returns: { message, senderName, richObjects }
|
|
*/
|
|
public function mapApprisePayload(array $data, string $roomToken = ''): array {
|
|
$this->logger->info('NCbotwebhooks: mapApprisePayload input', [
|
|
'app' => self::APP_ID,
|
|
'type' => $data['type'] ?? 'none',
|
|
'has_attachments' => !empty($data['attachments']),
|
|
'has_attachment' => !empty($data['attachment']),
|
|
'attachments' => json_encode($data['attachments'] ?? []),
|
|
'body' => $data['body'] ?? '',
|
|
'title' => $data['title'] ?? '',
|
|
]);
|
|
$parts = [];
|
|
|
|
// Display name: sender_name > title > subject > config default
|
|
$displayName = !empty($data['sender_name']) ? $data['sender_name']
|
|
: (!empty($data['title']) ? $data['title']
|
|
: (!empty($data['subject']) ? $data['subject']
|
|
: $this->config->getAppValue(self::APP_ID, 'sender_name', 'Webhook Bot')));
|
|
|
|
// Body
|
|
if (!empty($data['body'])) {
|
|
$parts[] = $data['body'];
|
|
}
|
|
|
|
// For image-type notifications, use the attachment URL as the message
|
|
if (!empty($data['type']) && $data['type'] === 'image') {
|
|
// Apprise sends image notifications with type=image and attachment (string URL)
|
|
$imageUrls = [];
|
|
if (!empty($data['attachment']) && is_string($data['attachment'])) {
|
|
$imageUrls[] = $data['attachment'];
|
|
}
|
|
// Collect image URLs and base64 attachments
|
|
$imageUrls = [];
|
|
$imageBases64 = [];
|
|
if (!empty($data['attachment']) && is_string($data['attachment'])) {
|
|
$imageUrls[] = $data['attachment'];
|
|
}
|
|
if (!empty($data['attachments']) && is_array($data['attachments'])) {
|
|
foreach ($data['attachments'] as $a) {
|
|
if (is_string($a)) {
|
|
$imageUrls[] = $a;
|
|
} elseif (is_array($a)) {
|
|
if (!empty($a['url'])) {
|
|
$imageUrls[] = $a['url'];
|
|
} elseif (!empty($a['path']) && (str_starts_with($a['path'], 'http://') || str_starts_with($a['path'], 'https://'))) {
|
|
$imageUrls[] = $a['path'];
|
|
} elseif (!empty($a['base64'])) {
|
|
$imageBases64[] = $a;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$richObjects = [];
|
|
if (!empty($imageUrls)) {
|
|
$richObjects = $this->processImageUrls($imageUrls, $roomToken);
|
|
}
|
|
foreach ($imageBases64 as $b64) {
|
|
$fileData = base64_decode($b64['base64'], true);
|
|
if ($fileData === false) {
|
|
continue;
|
|
}
|
|
$fileName = $b64['filename'] ?? $b64['name'] ?? 'attachment';
|
|
$mimeType = $b64['mimetype'] ?? $b64['mimeType'] ?? 'image/png';
|
|
$uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType);
|
|
if ($uploadPath !== null) {
|
|
$richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken);
|
|
if ($richObj !== null) {
|
|
$richObjects[] = $richObj;
|
|
}
|
|
}
|
|
}
|
|
if (!empty($imageUrls) || !empty($imageBases64)) {
|
|
// Use title as message if body is empty
|
|
if (empty($data['body'])) {
|
|
$message = !empty($data['title']) ? $data['title'] : '';
|
|
} else {
|
|
$message = implode("\n\n", $parts);
|
|
}
|
|
$senderName = $displayName;
|
|
return [
|
|
'message' => $message,
|
|
'senderName' => $senderName,
|
|
'displayName' => $displayName,
|
|
'richObjects' => $richObjects,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Type prefix for context (e.g. "[Warning]")
|
|
if (!empty($data['type'])) {
|
|
$typeLabels = [
|
|
'info' => '[Info]',
|
|
'success' => '[Success]',
|
|
'warning' => '[Warning]',
|
|
'error' => '[Error]',
|
|
];
|
|
if (isset($typeLabels[$data['type']])) {
|
|
array_unshift($parts, $typeLabels[$data['type']]);
|
|
}
|
|
}
|
|
|
|
$message = implode("\n\n", $parts);
|
|
|
|
// If body is empty but we have a title/subject, use it as the message
|
|
// (handles test notifications and simple alerts with no body)
|
|
if ($message === '') {
|
|
if (!empty($data['title'])) {
|
|
$message = $data['title'];
|
|
} elseif (!empty($data['subject'])) {
|
|
$message = $data['subject'];
|
|
}
|
|
}
|
|
|
|
// Sender name: use app name if available, otherwise default
|
|
$senderName = $displayName;
|
|
|
|
// Handle attachments: download files and upload to Talk
|
|
$richObjects = [];
|
|
if (!empty($data['attachments']) && is_array($data['attachments'])) {
|
|
foreach ($data['attachments'] as $attachment) {
|
|
if (!is_array($attachment)) {
|
|
continue;
|
|
}
|
|
|
|
$filePath = $attachment['path'] ?? '';
|
|
$this->logger->info('NCbotwebhooks: attachment path', [
|
|
'app' => self::APP_ID,
|
|
'path' => $filePath,
|
|
'url_key' => $attachment['url'] ?? 'none',
|
|
]);
|
|
if (str_starts_with($filePath, 'file://')) {
|
|
$localPath = substr($filePath, 7); // Strip 'file://' prefix
|
|
if (is_file($localPath) && is_readable($localPath)) {
|
|
$fileData = file_get_contents($localPath);
|
|
if ($fileData === false) {
|
|
continue;
|
|
}
|
|
|
|
$fileName = $attachment['name'] ?? basename($localPath);
|
|
$mimeType = $attachment['mimeType'] ?? mime_content_type($localPath);
|
|
|
|
$uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType);
|
|
if ($uploadPath !== null) {
|
|
$richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken);
|
|
if ($richObj !== null) {
|
|
$richObjects[] = $richObj;
|
|
}
|
|
}
|
|
}
|
|
} elseif (!empty($attachment['url'] ?? '')) {
|
|
// Remote URL attachment
|
|
$imageData = $this->downloadImage($attachment['url']);
|
|
if ($imageData === null) {
|
|
continue;
|
|
}
|
|
|
|
$fileName = $attachment['name'] ?? 'attachment';
|
|
$uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']);
|
|
if ($uploadPath !== null) {
|
|
$richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken);
|
|
if ($richObj !== null) {
|
|
$richObjects[] = $richObj;
|
|
}
|
|
}
|
|
} elseif (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) {
|
|
// HA rest_command sends HTTP URLs in 'path' — treat as remote URL
|
|
$richObjects = array_merge($richObjects, $this->processImageUrls([$filePath], $roomToken));
|
|
} elseif (!empty($attachment['url'] ?? '')) {
|
|
// Remote URL attachment
|
|
$imageData = $this->downloadImage($attachment['url']);
|
|
if ($imageData === null) {
|
|
continue;
|
|
}
|
|
|
|
$fileName = $attachment['name'] ?? 'attachment';
|
|
$uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']);
|
|
if ($uploadPath !== null) {
|
|
$richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken);
|
|
if ($richObj !== null) {
|
|
$richObjects[] = $richObj;
|
|
}
|
|
}
|
|
} elseif (!empty($attachment['base64'] ?? '')) {
|
|
// Base64-encoded attachment (Apprise library JSON method)
|
|
$fileData = base64_decode($attachment['base64'], true);
|
|
if ($fileData === false) {
|
|
continue;
|
|
}
|
|
|
|
$fileName = $attachment['filename'] ?? $attachment['name'] ?? 'attachment';
|
|
$mimeType = $attachment['mimetype'] ?? $attachment['mimeType'] ?? 'application/octet-stream';
|
|
$uploadPath = $this->uploadImage($roomToken, $fileName, $fileData, $mimeType);
|
|
if ($uploadPath !== null) {
|
|
$richObj = $this->buildRichObject($uploadPath, $mimeType, $roomToken);
|
|
if ($richObj !== null) {
|
|
$richObjects[] = $richObj;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'message' => $message,
|
|
'senderName' => $senderName,
|
|
'displayName' => $displayName,
|
|
'richObjects' => $richObjects,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Set sender name default.
|
|
*/
|
|
public function setSenderName(string $name): void {
|
|
$this->config->setAppValue(self::APP_ID, 'sender_name', $name);
|
|
}
|
|
|
|
// ── Image handling ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Download an image from a URL.
|
|
* Returns ['data' => binary, 'mimeType' => string] or null on failure.
|
|
*/
|
|
public function downloadImage(string $url): ?array {
|
|
try {
|
|
$client = $this->clientService->newClient();
|
|
$response = $client->get($url, [
|
|
'timeout' => 15,
|
|
'nextcloud' => [
|
|
'allow_local_address' => true,
|
|
],
|
|
]);
|
|
|
|
$mimeType = $response->getHeader('Content-Type') ?: 'image/png';
|
|
$body = $response->getBody();
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
$this->logger->info('NCbotwebhooks: image download response', [
|
|
'app' => self::APP_ID,
|
|
'url' => $url,
|
|
'status_code' => $statusCode,
|
|
'body_length' => strlen($body),
|
|
]);
|
|
if (strlen($body) === 0) {
|
|
$this->logger->warning('NCbotwebhooks: image download returned empty body', [
|
|
'app' => self::APP_ID,
|
|
'url' => $url,
|
|
'status_code' => $statusCode,
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
return ['data' => $body, 'mimeType' => $mimeType];
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: Failed to download image: ' . $url . ' — ' . $e->getMessage(), ['app' => self::APP_ID]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload an image to the bot user's files under IMAGES_DIR/<roomToken>/.
|
|
* Filename format: talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext
|
|
* Returns the filecache path (e.g. nc_bot_webhooks-images/abc123/talk-bot 2026-06-13 01.35.19.123456_filename.png) or null on failure.
|
|
*/
|
|
public function uploadImage(string $roomToken, string $filename, string $data, string $mimeType): ?string {
|
|
$bot = $this->userManager->get('talk-bot');
|
|
if (!$bot) {
|
|
$this->logger->error('NCbotwebhooks: uploadImage — bot user not found', ['app' => self::APP_ID]);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$this->logger->info('NCbotwebhooks: uploadImage start', [
|
|
'app' => self::APP_ID,
|
|
'roomToken' => $roomToken,
|
|
'filename' => $filename,
|
|
'size' => strlen($data),
|
|
'bot_uid' => $bot->getUID(),
|
|
]);
|
|
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
|
$this->logger->info('NCbotwebhooks: getUserFolder succeeded', [
|
|
'app' => self::APP_ID,
|
|
'userFolder_type' => get_class($userFolder),
|
|
]);
|
|
|
|
// Ensure IMAGES_DIR exists (e.g. nc_bot_webhooks-images/)
|
|
$imagesDir = null;
|
|
try {
|
|
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
|
$this->logger->info('NCbotwebhooks: images dir already exists', [
|
|
'app' => self::APP_ID,
|
|
'path' => self::IMAGES_DIR,
|
|
]);
|
|
} catch (\OCP\Files\NotFoundException $e) {
|
|
$this->logger->info('NCbotwebhooks: creating images dir', [
|
|
'app' => self::APP_ID,
|
|
'path' => self::IMAGES_DIR,
|
|
]);
|
|
$userFolder->newFolder(self::IMAGES_DIR);
|
|
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
|
} catch (\Error $e) {
|
|
$this->logger->error('NCbotwebhooks: get threw Error on images dir: ' . $e->getMessage(), [
|
|
'app' => self::APP_ID,
|
|
'path' => self::IMAGES_DIR,
|
|
'exception' => get_class($e),
|
|
]);
|
|
throw $e;
|
|
}
|
|
|
|
// Ensure per-room subdirectory exists
|
|
$roomDir = null;
|
|
try {
|
|
$roomDir = $imagesDir->get($roomToken);
|
|
$this->logger->info('NCbotwebhooks: room dir already exists', [
|
|
'app' => self::APP_ID,
|
|
'path' => $roomToken,
|
|
]);
|
|
} catch (\OCP\Files\NotFoundException $e) {
|
|
$this->logger->info('NCbotwebhooks: creating room dir', [
|
|
'app' => self::APP_ID,
|
|
'parent' => self::IMAGES_DIR,
|
|
'path' => $roomToken,
|
|
]);
|
|
$imagesDir->newFolder($roomToken);
|
|
$roomDir = $imagesDir->get($roomToken);
|
|
} catch (\Error $e) {
|
|
$this->logger->error('NCbotwebhooks: get threw Error on room dir: ' . $e->getMessage(), [
|
|
'app' => self::APP_ID,
|
|
'parent' => self::IMAGES_DIR,
|
|
'path' => $roomToken,
|
|
'exception' => get_class($e),
|
|
]);
|
|
throw $e;
|
|
}
|
|
|
|
// Build timestamped filename: talk-bot YYYY-MM-DD hh.mm.ss.microsec.ext
|
|
$safeFilename = $this->sanitizeFilename($filename);
|
|
$ext = pathinfo($safeFilename, PATHINFO_EXTENSION);
|
|
$base = pathinfo($safeFilename, PATHINFO_FILENAME);
|
|
$timestamp = date('Y-m-d H.i.s') . '.' . sprintf('%06d', (int)(microtime(true) * 1000) % 1000000);
|
|
$relativePath = 'talk-bot ' . $timestamp . '_' . $base . '.' . $ext;
|
|
|
|
$this->logger->info('NCbotwebhooks: writing file', [
|
|
'app' => self::APP_ID,
|
|
'original' => $safeFilename,
|
|
'path' => $relativePath,
|
|
]);
|
|
$roomDir->newFile($relativePath, $data);
|
|
$this->logger->info('NCbotwebhooks: file written successfully', [
|
|
'app' => self::APP_ID,
|
|
'path' => self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath,
|
|
]);
|
|
|
|
// Verify filecache entry was created by newFile.
|
|
$prefix2 = strtolower($this->config->getSystemValueString('dbtableprefix', 'oc_'));
|
|
if (substr($prefix2, -1) !== '_') {
|
|
$prefix2 .= '_';
|
|
}
|
|
$fcTable = $prefix2 . 'filecache';
|
|
try {
|
|
$pdo2 = $this->db->getInner()->getNativeConnection();
|
|
$stmtFc = $pdo2->prepare(
|
|
'SELECT "fileid","storage","path","path_hash","name","mimetype","size" FROM "' . $fcTable . '" WHERE "path" = ?',
|
|
);
|
|
$stmtFc->execute([self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath]);
|
|
$fcRow = $stmtFc->fetchAll(\PDO::FETCH_ASSOC);
|
|
$stmtFc->closeCursor();
|
|
$this->logger->info('NCbotwebhooks: uploadImage filecache check', [
|
|
'app' => self::APP_ID,
|
|
'found' => count($fcRow) > 0,
|
|
'entries' => $fcRow,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('NCbotwebhooks: uploadImage filecache check failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
|
}
|
|
|
|
return self::IMAGES_DIR . '/' . $roomToken . '/' . $relativePath;
|
|
} catch (\Error $e) {
|
|
$this->logger->error('NCbotwebhooks: uploadImage Error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('NCbotwebhooks: Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize a filename to prevent path traversal.
|
|
*/
|
|
private function sanitizeFilename(string $filename): string {
|
|
return basename($filename);
|
|
}
|
|
|
|
|
|
/**
|
|
* Build rich object data for a Talk message from an uploaded file path.
|
|
* Creates a public link share so Talk can resolve the rich object.
|
|
*/
|
|
public function buildRichObject(string $filePath, string $mimeType, string $roomToken): ?array {
|
|
$this->logger->info('NCbotwebhooks: buildRichObject ENTER', ['app' => self::APP_ID, 'filePath' => $filePath, 'roomToken' => $roomToken]);
|
|
$bot = $this->userManager->get('talk-bot');
|
|
if (!$bot) {
|
|
$this->logger->info('NCbotwebhooks: buildRichObject EXIT noBot', ['app' => self::APP_ID]);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$safeFilename = basename($filePath);
|
|
$this->logger->info('NCbotwebhooks: richObject step=basename', ['app' => self::APP_ID]);
|
|
|
|
// filePath is the full filecache path returned by uploadImage (e.g. nc_bot_webhooks-images/abc123/talk-bot 2026-06-13 01.35.19.123456_filename.png).
|
|
$fileCachePath = $filePath;
|
|
$this->logger->info('NCbotwebhooks: richObject step=buildPath', ['app' => self::APP_ID, 'path' => $fileCachePath]);
|
|
|
|
// Use PDO directly to avoid any DBConnection method that could trigger lazy init.
|
|
$this->logger->info('NCbotwebhooks: richObject step=getPdo', ['app' => self::APP_ID]);
|
|
$pdo = $this->db->getInner()->getNativeConnection();
|
|
$this->logger->info('NCbotwebhooks: richObject step=pdoOk', ['app' => self::APP_ID]);
|
|
|
|
// Get table prefix from system config (safe — no DB abstraction layer).
|
|
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
|
|
$prefix = strtolower($prefix);
|
|
if ($prefix === '') {
|
|
$prefix = 'oc_';
|
|
}
|
|
// Ensure trailing underscore
|
|
if (substr($prefix, -1) !== '_') {
|
|
$prefix = $prefix . '_';
|
|
}
|
|
|
|
$fileCacheTable = $prefix . 'filecache';
|
|
$shareTable = $prefix . 'share';
|
|
$this->logger->info('NCbotwebhooks: richObject step=tables', ['app' => self::APP_ID, 'filecache' => $fileCacheTable, 'share' => $shareTable]);
|
|
|
|
// Resolve the bot user's home storage ID from the storages table.
|
|
// Storage IDs are instance-specific; hardcoding '1' breaks on many setups.
|
|
$storageId = null;
|
|
try {
|
|
$storagesTable = $prefix . 'storages';
|
|
$stmt = $pdo->prepare('SELECT "numeric_id" FROM "' . $storagesTable . '" WHERE "id" = ?', []);
|
|
$stmt->execute(['home::' . $bot->getUID()]);
|
|
$storageId = (int)$stmt->fetch(\PDO::FETCH_COLUMN);
|
|
$stmt->closeCursor();
|
|
$this->logger->info('NCbotwebhooks: richObject step=storageId', ['app' => self::APP_ID, 'storageId' => $storageId]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('NCbotwebhooks: richObject step=storageIdFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
}
|
|
|
|
$this->logger->info('NCbotwebhooks: richObject step=prepareSelect', ['app' => self::APP_ID]);
|
|
$stmt = $pdo->prepare(
|
|
'SELECT fileid, path, mimetype, permissions, size, storage'
|
|
. ' FROM "' . $fileCacheTable . '"'
|
|
. ' WHERE "path_hash" = ?',
|
|
);
|
|
$pathHash = md5($fileCachePath);
|
|
$this->logger->info('NCbotwebhooks: richObject step=executeSelect', ['app' => self::APP_ID, 'pathHash' => $pathHash, 'path' => $fileCachePath]);
|
|
$stmt->execute([$pathHash]);
|
|
$this->logger->info('NCbotwebhooks: richObject step=fetch', ['app' => self::APP_ID]);
|
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
$stmt->closeCursor();
|
|
$this->logger->info('NCbotwebhooks: richObject step=rowFetched', ['app' => self::APP_ID, 'found' => $row !== false, 'columns' => array_keys($row ?: []), 'data' => $row]);
|
|
|
|
// Path-based fallback: if path_hash lookup found nothing, try LIKE match on path column.
|
|
// This catches cases where the filecache entry exists but path_hash didn't match.
|
|
if (!$row) {
|
|
$this->logger->info('NCbotwebhooks: richObject step=pathFallback', ['app' => self::APP_ID, 'path' => $fileCachePath]);
|
|
$stmt2 = $pdo->prepare(
|
|
'SELECT fileid, path, mimetype, permissions, size, storage'
|
|
. ' FROM "' . $fileCacheTable . '"'
|
|
. ' WHERE "path" LIKE ?',
|
|
);
|
|
$stmt2->execute([self::IMAGES_DIR . '/%']);
|
|
$rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
|
|
$stmt2->closeCursor();
|
|
$this->logger->info('NCbotwebhooks: richObject step=pathFallbackRows', ['app' => self::APP_ID, 'count' => count($rows)]);
|
|
foreach ($rows as $r) {
|
|
if ($r['path'] === $fileCachePath) {
|
|
$row = $r;
|
|
$this->logger->info('NCbotwebhooks: richObject step=pathFallbackMatch', ['app' => self::APP_ID, 'fileId' => $r['fileid']]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$row) {
|
|
// Manual filecache insertion — the file exists on disk but the entry is missing.
|
|
// This can happen if the scanner didn't run or the entry was deleted.
|
|
$this->logger->warning('NCbotwebhooks: richObject step=insertFc', ['app' => self::APP_ID, 'path' => $fileCachePath, 'storageId' => $storageId]);
|
|
try {
|
|
// Look up mimetype ID from the mimetype lookup table.
|
|
// In NC33, mimetype is a bigint (ID), not a string.
|
|
$mimeTypeTable = $prefix . 'mimetypes';
|
|
$stmtMt = $pdo->prepare('SELECT "id" FROM "' . $mimeTypeTable . '" WHERE "mimetype" = ?');
|
|
$stmtMt->execute([$mimeType]);
|
|
$mimeTypeId = (int)$stmtMt->fetch(\PDO::FETCH_COLUMN);
|
|
$stmtMt->closeCursor();
|
|
if ($mimeTypeId <= 0) {
|
|
$mimeTypeId = 1; // fallback
|
|
}
|
|
|
|
// Look up mimepart ID (top-level type, e.g. 'image' from 'image/jpeg').
|
|
$mimePart = explode('/', $mimeType)[0];
|
|
$stmtMp = $pdo->prepare('SELECT "id" FROM "' . $mimeTypeTable . '" WHERE "mimetype" = ?');
|
|
$stmtMp->execute([$mimePart]);
|
|
$mimePartId = (int)$stmtMp->fetch(\PDO::FETCH_COLUMN);
|
|
$stmtMp->closeCursor();
|
|
if ($mimePartId <= 0) {
|
|
$mimePartId = 1; // fallback
|
|
}
|
|
|
|
// Look up the room directory fileid to use as parent.
|
|
// filecache 'path' stores relative paths without leading slash.
|
|
$roomDirPath = self::IMAGES_DIR . '/' . $roomToken;
|
|
$roomDirHash = md5($roomDirPath);
|
|
$stmtP = $pdo->prepare(
|
|
'SELECT "fileid" FROM "' . $fileCacheTable . '" WHERE "path_hash" = ? AND "name" = ?',
|
|
);
|
|
$stmtP->execute([$roomDirHash, $roomToken]);
|
|
$parentFileId = (int)$stmtP->fetch(\PDO::FETCH_COLUMN);
|
|
$stmtP->closeCursor();
|
|
if ($parentFileId <= 0) {
|
|
// Parent not in filecache yet — use 0 (root) as fallback.
|
|
$parentFileId = 0;
|
|
}
|
|
|
|
$stmtInsert = $pdo->prepare(
|
|
'INSERT INTO "' . $fileCacheTable . '"'
|
|
. ' ("storage","path","path_hash","parent","name","mimetype","mimepart","size","mtime","storage_mtime","encrypted","unencrypted_size","etag","permissions")'
|
|
. ' VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
);
|
|
$stmtInsert->execute([
|
|
$storageId ?: 1,
|
|
$fileCachePath,
|
|
$pathHash,
|
|
$parentFileId,
|
|
$safeFilename,
|
|
$mimeTypeId,
|
|
$mimePartId,
|
|
0, // size (not yet known at this point)
|
|
(int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()),
|
|
(int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()),
|
|
0, // encrypted
|
|
0, // unencrypted_size
|
|
'', // etag
|
|
\OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_DELETE,
|
|
]);
|
|
$fileId = (int)$pdo->lastInsertId($prefix . 'filecache_fileid_seq');
|
|
$this->logger->info('NCbotwebhooks: richObject step=fcInserted', ['app' => self::APP_ID, 'fileId' => $fileId]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->error('NCbotwebhooks: richObject step=fcInsertFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
return null;
|
|
}
|
|
} else {
|
|
$fileId = (int)$row['fileid'];
|
|
}
|
|
|
|
$fileMimeType = $row !== false ? ($row['mimetype'] ?? $mimeType) : $mimeType;
|
|
$this->logger->info('NCbotwebhooks: richObject step=fileFound', ['app' => self::APP_ID, 'fileId' => $fileId]);
|
|
|
|
$this->logger->info('NCbotwebhooks: richObject step=prepareInsert', ['app' => self::APP_ID]);
|
|
|
|
// Resolve the actual Node object so createShare() doesn't have to
|
|
// lazily resolve it via getFirstNodeById() which can fail on
|
|
// LazyUserFolder or with manually-inserted filecache entries.
|
|
$node = null;
|
|
try {
|
|
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
|
$node = $userFolder->get($fileCachePath);
|
|
$this->logger->info('NCbotwebhooks: richObject step=nodeResolved', ['app' => self::APP_ID, 'nodeType' => get_class($node)]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('NCbotwebhooks: richObject step=nodeResolveFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
}
|
|
|
|
// Create a room share so Talk's SystemMessage parser can resolve the
|
|
// file via getFileFromShare(). TYPE_ROOM shares are stored in the
|
|
// share_external table (as external shares) and resolved by fileId
|
|
// lookup in the original owner's folder — matching how
|
|
// RecordingService::shareToChat() works.
|
|
$share = $this->shareManager->newShare();
|
|
if ($node !== null) {
|
|
$share->setNode($node)
|
|
->setShareType(IShare::TYPE_ROOM)
|
|
->setSharedBy($bot->getUID())
|
|
->setSharedWith($roomToken)
|
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
|
} else {
|
|
$share->setNodeId($fileId)
|
|
->setShareType(IShare::TYPE_ROOM)
|
|
->setSharedBy($bot->getUID())
|
|
->setSharedWith($roomToken)
|
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
|
}
|
|
|
|
$shareId = null;
|
|
try {
|
|
$created = $this->shareManager->createShare($share);
|
|
$shareId = (int)$created->getId();
|
|
$this->logger->info('NCbotwebhooks: richObject step=shareCreated', ['app' => self::APP_ID, 'shareId' => $shareId]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('NCbotwebhooks: richObject step=shareCreateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
}
|
|
|
|
// Compute actual file size from disk (filecache size may be 0 if manually inserted).
|
|
$actualSize = 0;
|
|
try {
|
|
$basePath = $this->config->getSystemValueString('datadirectory', '/var/www/nextcloud/data');
|
|
$filePath = rtrim($basePath, '/') . '/' . ltrim($bot->getUID(), '/') . '/files/' . $fileCachePath;
|
|
if (file_exists($filePath)) {
|
|
$s = filesize($filePath);
|
|
if ($s !== false && $s > 0) {
|
|
$actualSize = $s;
|
|
}
|
|
// Update filecache entry with actual size and etag so Talk server
|
|
// can resolve the fileId for rich object embedding.
|
|
try {
|
|
$fileCacheTable = $prefix . 'filecache';
|
|
$etag = substr(md5_file($filePath) . (string)(int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()), 0, 40);
|
|
$this->db->executeUpdate(
|
|
'UPDATE "' . $fileCacheTable . '" SET "size" = ?, "unencrypted_size" = ?, "etag" = ?, "mtime" = ? WHERE "fileid" = ?',
|
|
[$actualSize, $actualSize, $etag, (int)$this->config->getSystemValueInt('phpfileclient_mapping_localtime', time()), $fileId],
|
|
);
|
|
$this->logger->info('NCbotwebhooks: richObject step=filecacheUpdated', ['app' => self::APP_ID, 'fileId' => $fileId, 'size' => $actualSize, 'etag' => $etag]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('NCbotwebhooks: richObject step=filecacheUpdateFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Final fallback: 0
|
|
}
|
|
|
|
// Create a public link share so Talk's linkifier can scrape the
|
|
// /s/{token} page for og:image meta tags and render rich preview
|
|
// cards. This compensates for Talk 23.0.6's lack of a fallback
|
|
// when the file_shared system message can't resolve the file in
|
|
// the strict path (chat history for regular users).
|
|
$linkShareId = null;
|
|
$linkToken = null;
|
|
$linkPublicUrl = '';
|
|
try {
|
|
$linkShare = $this->shareManager->newShare();
|
|
if ($node !== null) {
|
|
$linkShare->setNode($node)
|
|
->setShareType(IShare::TYPE_LINK)
|
|
->setSharedBy($bot->getUID())
|
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
|
} else {
|
|
$linkShare->setNodeId($fileId)
|
|
->setShareType(IShare::TYPE_LINK)
|
|
->setSharedBy($bot->getUID())
|
|
->setPermissions(\OCP\Constants::PERMISSION_READ);
|
|
}
|
|
|
|
$createdLink = $this->shareManager->createShare($linkShare);
|
|
$linkToken = $createdLink->getToken();
|
|
$linkShareId = (int)$createdLink->getId();
|
|
$linkPublicUrl = $this->urlGen->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', ['token' => $linkToken]);
|
|
|
|
$this->logger->info('NCbotwebhooks: richObject step=linkShareCreated', ['app' => self::APP_ID, 'linkShareId' => $linkShareId, 'token' => $linkToken]);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('NCbotwebhooks: richObject step=linkShareFail', ['app' => self::APP_ID, 'error' => $e->getMessage()]);
|
|
}
|
|
|
|
$fullPublicUrl = $linkPublicUrl;
|
|
$fullDownloadUrl = '';
|
|
|
|
return [
|
|
'fileId' => $fileId,
|
|
'mimeType' => $mimeType,
|
|
'publicUrl' => $fullPublicUrl,
|
|
'downloadUrl' => $fullDownloadUrl,
|
|
'shareToken' => $linkToken,
|
|
'shareId' => $shareId ?? null,
|
|
'filename' => $safeFilename,
|
|
'fileCachePath' => $fileCachePath,
|
|
'actualSize' => $actualSize,
|
|
];
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Failed to build rich object (Exception): ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
|
|
$this->logger->info('NCbotwebhooks: buildRichObject EXIT exception: ' . $e->getMessage() . ' (' . $e->getFile() . ':' . $e->getLine() . ')', ['app' => self::APP_ID]);
|
|
return null;
|
|
} catch (\Error $e) {
|
|
$this->logger->warning('NCbotwebhooks: buildRichObject ERROR=' . get_class($e) . ' msg=' . $e->getMessage() . ' file=' . $e->getFile() . ':' . $e->getLine(), ['app' => self::APP_ID]);
|
|
return null;
|
|
}
|
|
$this->logger->info('NCbotwebhooks: buildRichObject EXIT success', ['app' => self::APP_ID]);
|
|
}
|
|
|
|
// ── Chat API: post message to Talk room ──────────────────────
|
|
|
|
/**
|
|
* Post a message to a Talk room.
|
|
*
|
|
* When rich objects (uploaded images) are provided, each file is shared
|
|
* via a public link. The share URLs are appended to the message text so
|
|
* Talk's linkifier can scrape the og:image meta tag from the public share
|
|
* page and render rich link preview cards with image thumbnails.
|
|
*
|
|
* @param string $roomToken Talk room token
|
|
* @param string $message Message text
|
|
* @param string $senderName Discord sender name (embedded in message text)
|
|
* @param array $richObjects Rich object data (optional)
|
|
* @return bool Success
|
|
*/
|
|
public function postToRoom(
|
|
string $roomToken,
|
|
string $message,
|
|
string $senderName,
|
|
array $richObjects = [],
|
|
): bool {
|
|
$botPassword = $this->getBotPassword();
|
|
if ($botPassword === null) {
|
|
$this->logger->error('NCbotwebhooks: bot password not configured', ['app' => self::APP_ID]);
|
|
return false;
|
|
}
|
|
|
|
// Check bot is enabled for this room (via AppConfig)
|
|
if (!$this->isBotEnabledForRoom($roomToken)) {
|
|
$this->logger->warning('NCbotwebhooks: bot not enabled for room', [
|
|
'app' => self::APP_ID,
|
|
'room_token' => $roomToken,
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
$baseUrl = $this->getBaseUrl();
|
|
if ($baseUrl === '') {
|
|
$this->logger->error('NCbotwebhooks: base URL not configured', ['app' => self::APP_ID]);
|
|
return false;
|
|
}
|
|
|
|
// Get bot's actual display name from the room participant record.
|
|
// In Talk 14+, actorDisplayName MUST match the participant's display name
|
|
// or the message is silently dropped.
|
|
$botDisplayName = $this->getBotDisplayNameForRoom($roomToken);
|
|
|
|
// Basic auth: base64('talk-bot:' . bot_password)
|
|
$credentials = base64_encode('talk-bot:' . $botPassword);
|
|
|
|
// Append public share URLs to the message text as a fallback so Talk's
|
|
// linkifier can scrape OG data and render rich link preview cards with
|
|
// image thumbnails, in case the file_shared system message fails to
|
|
// produce an inline image preview.
|
|
$shareUrls = [];
|
|
if (!empty($richObjects)) {
|
|
foreach ($richObjects as $richObject) {
|
|
$shareUrl = $richObject['shareUrl'] ?? $richObject['share_url'] ?? null;
|
|
$publicUrl = $richObject['publicUrl'] ?? $richObject['public_url'] ?? null;
|
|
$fileName = $richObject['filename'] ?? 'image';
|
|
|
|
// Prefer the public /s/{token} URL — that's the one Talk's linkifier
|
|
// will scrape for og:image. Fall back to any available share URL.
|
|
$url = $publicUrl ?: $shareUrl;
|
|
if ($url) {
|
|
$shareUrls[] = $fileName . ': ' . $url;
|
|
}
|
|
}
|
|
}
|
|
if (!empty($shareUrls)) {
|
|
$message .= "\n\n" . implode("\n", $shareUrls);
|
|
}
|
|
|
|
// Build a file_shared system message with the share ID and metaData
|
|
// so Talk's SystemMessage parser can resolve the file via the room
|
|
// share. RecordingService::shareToChat() uses the same pattern.
|
|
$richObject = $richObjects[0] ?? null;
|
|
$shareId = (string)($richObject['shareId'] ?? '');
|
|
$mimeType = $richObject['mimeType'] ?? 'application/octet-stream';
|
|
$messageType = match (true) {
|
|
str_starts_with($mimeType, 'image/') => 'comment',
|
|
str_starts_with($mimeType, 'video/') => 'comment',
|
|
str_starts_with($mimeType, 'audio/') => 'comment',
|
|
default => 'comment',
|
|
};
|
|
$systemMessage = json_encode([
|
|
'message' => 'file_shared',
|
|
'parameters' => [
|
|
'share' => $shareId !== '' ? $shareId : null,
|
|
'metaData' => [
|
|
'mimeType' => $mimeType,
|
|
'messageType' => $messageType,
|
|
],
|
|
],
|
|
]);
|
|
|
|
// Get the Room object — required for ChatManager::addSystemMessage().
|
|
// We use getRoomByToken() which works without authentication.
|
|
$room = null;
|
|
try {
|
|
$room = $this->talkManager->getRoomByToken($roomToken);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('NCbotwebhooks: failed to get room for token ' . $roomToken, [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
$this->logger->info('NCbotwebhooks: sending file_shared message to Talk', [
|
|
'app' => self::APP_ID,
|
|
'room_token' => $roomToken,
|
|
'system_message' => $systemMessage,
|
|
'message_text' => $message,
|
|
'debug' => [
|
|
'richObject_keys' => array_keys($richObject ?? []),
|
|
'richObject_fileId' => $richObject['fileId'] ?? null,
|
|
'richObject_shareId' => $richObject['shareId'] ?? null,
|
|
'richObject_fileCachePath' => $richObject['fileCachePath'] ?? null,
|
|
'richObject_downloadUrl' => $richObject['downloadUrl'] ?? null,
|
|
'richObject_publicUrl' => $richObject['publicUrl'] ?? null,
|
|
'richObject_shareToken' => $richObject['shareToken'] ?? null,
|
|
'richObject_filename' => $richObject['filename'] ?? null,
|
|
'richObject_mimeType' => $richObject['mimeType'] ?? null,
|
|
'shareId_used' => $shareId,
|
|
],
|
|
]);
|
|
|
|
// Use ChatManager::addSystemMessage() directly (PHP API) so the
|
|
// system message is created with the correct verb ('object_shared')
|
|
// and the attachment entry is created. The HTTP sendMessage() endpoint
|
|
// only creates regular 'comment' messages — it cannot create system
|
|
// messages.
|
|
try {
|
|
$bot = $this->userManager->get('talk-bot');
|
|
$actorType = Attendee::ACTOR_USERS;
|
|
$actorId = 'talk-bot';
|
|
|
|
$this->chatManager->addSystemMessage(
|
|
$room,
|
|
null, // participant — not needed for webhook bot; mention perms check uses null-safe operator
|
|
$actorType,
|
|
$actorId,
|
|
$systemMessage,
|
|
new \DateTime('now', new \DateTimeZone('UTC')),
|
|
true, // sendNotifications
|
|
);
|
|
$this->logger->info('NCbotwebhooks: system message posted to room ' . $roomToken, [
|
|
'app' => self::APP_ID,
|
|
]);
|
|
// Verify the system message was created with correct fileId/shareId
|
|
$this->logger->info('NCbotwebhooks: system message verification', [
|
|
'app' => self::APP_ID,
|
|
'fileId' => $richObject['fileId'] ?? null,
|
|
'shareId' => $shareId,
|
|
'fileCachePath' => $richObject['fileCachePath'] ?? null,
|
|
'botUserExists' => $bot !== null,
|
|
'botUserId' => $bot ? $bot->getUID() : 'null',
|
|
]);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('NCbotwebhooks: addSystemMessage failed: ' . $e->getMessage(), [
|
|
'app' => self::APP_ID,
|
|
'room_token' => $roomToken,
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
// Send the text message (with appended share URLs) via HTTP API so
|
|
// Talk's @RequireParticipant middleware creates/finds the bot
|
|
// participant record, and the message gets proper moderation checks.
|
|
// This provides the human-readable text that the system message
|
|
// does not include.
|
|
if (trim($message) !== '') {
|
|
$textEndpoint = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $roomToken
|
|
. '?message=' . urlencode($message)
|
|
. ($botDisplayName ? '&actorDisplayName=' . urlencode($botDisplayName) : '');
|
|
|
|
try {
|
|
$client = $this->clientService->newClient();
|
|
$textResponse = $client->post($textEndpoint, [
|
|
'headers' => [
|
|
'OCS-Expect-Formatted' => 'json',
|
|
'OCS-APIRequest' => '1',
|
|
'Content-Type' => 'application/json',
|
|
'Authorization' => 'Basic ' . $credentials,
|
|
],
|
|
'body' => '{}',
|
|
'nextcloud' => [
|
|
'allow_local_address' => true,
|
|
],
|
|
]);
|
|
|
|
$textStatus = $textResponse->getStatusCode();
|
|
if ($textStatus >= 200 && $textStatus < 300) {
|
|
$this->logger->info('NCbotwebhooks: text message posted to room ' . $roomToken, [
|
|
'app' => self::APP_ID,
|
|
]);
|
|
} else {
|
|
$this->logger->warning('NCbotwebhooks: text message POST returned ' . $textStatus, [
|
|
'app' => self::APP_ID,
|
|
'room_token' => $roomToken,
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: text message POST failed: ' . $e->getMessage(), [
|
|
'app' => self::APP_ID,
|
|
'room_token' => $roomToken,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if the bot is enabled for a specific room (via AppConfig).
|
|
*/
|
|
public function isBotEnabledForRoom(string $roomToken): bool {
|
|
$rooms = $this->getRooms();
|
|
return isset($rooms[$roomToken]);
|
|
}
|
|
|
|
/**
|
|
* Get the bot's display name as registered in a Talk room.
|
|
* Falls back to 'talk-bot' if not found.
|
|
*/
|
|
public function getBotDisplayNameForRoom(string $roomToken): string {
|
|
// Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+)
|
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
|
if ($roomTable === null) {
|
|
return 'talk-bot';
|
|
}
|
|
|
|
try {
|
|
$stmt = $this->db->executeQuery(
|
|
'SELECT id FROM "' . $roomTable . '" WHERE token = ?',
|
|
[$roomToken],
|
|
);
|
|
$roomId = (int)$stmt->fetchOne();
|
|
$stmt->closeCursor();
|
|
|
|
if ($roomId === 0) {
|
|
return 'talk-bot';
|
|
}
|
|
|
|
$attendee = $this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot');
|
|
return $attendee->getDisplayName() ?: 'talk-bot';
|
|
} catch (DoesNotExistException $e) {
|
|
return 'talk-bot';
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: failed to get bot display name for room ' . $roomToken, [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
return 'talk-bot';
|
|
}
|
|
}
|
|
|
|
// ── Image cleanup ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Purge images older than retention period.
|
|
*/
|
|
public function purgeOldImages(): int {
|
|
$retentionDays = $this->getRetentionDays();
|
|
$cutoff = time() - ($retentionDays * 86400);
|
|
|
|
$bot = $this->userManager->get('talk-bot');
|
|
if (!$bot) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
$userFolder = $this->rootFolder->getUserFolder($bot->getUID());
|
|
// LazyFolder compatibility — use get() with fallback
|
|
$imagesDir = null;
|
|
try {
|
|
$imagesDir = $userFolder->get(self::IMAGES_DIR);
|
|
} catch (\OCP\Files\NotFoundException $e) {
|
|
return 0; // No images directory yet
|
|
}
|
|
return $this->purgeFolder($imagesDir, $cutoff);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively purge files older than cutoff from a folder.
|
|
*/
|
|
private function purgeFolder(Folder $folder, int $cutoff): int {
|
|
$count = 0;
|
|
|
|
foreach ($folder->getDirectoryListing() as $node) {
|
|
if ($node->getMTime() < $cutoff) {
|
|
$node->delete();
|
|
$count++;
|
|
} elseif ($node instanceof Folder) {
|
|
$count += $this->purgeFolder($node, $cutoff);
|
|
}
|
|
}
|
|
|
|
// Clean up empty subdirectories
|
|
foreach ($folder->getDirectoryListing() as $node) {
|
|
if ($node instanceof Folder && $node->getFolderInfo() === null) {
|
|
$node->delete();
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
// ── Config save (bulk) ────────────────────────────────────────
|
|
|
|
/**
|
|
* Save all config from the settings UI in one call.
|
|
*
|
|
* @param array $config {
|
|
* bot_password?: string,
|
|
* retention_days?: int,
|
|
* rooms?: array<string, string>,
|
|
* disabled_rooms?: array<string>,
|
|
* auth_tokens?: array<string, string[]>,
|
|
* sender_name?: string
|
|
* }
|
|
*/
|
|
public function saveConfig(array $config): void {
|
|
if (isset($config['bot_password']) && $config['bot_password'] !== '') {
|
|
$validation = $this->validateBotPassword($config['bot_password']);
|
|
if (!$validation['valid']) {
|
|
throw new \Exception($validation['error']);
|
|
}
|
|
$this->setBotPassword($config['bot_password']);
|
|
}
|
|
|
|
if (isset($config['retention_days'])) {
|
|
$this->setRetentionDays((int) $config['retention_days']);
|
|
}
|
|
|
|
if (isset($config['rooms'])) {
|
|
$rooms = $config['rooms'];
|
|
} else {
|
|
$rooms = $this->getRooms();
|
|
}
|
|
|
|
// Remove explicitly disabled rooms from the configured set
|
|
if (isset($config['disabled_rooms']) && is_array($config['disabled_rooms'])) {
|
|
foreach ($config['disabled_rooms'] as $token) {
|
|
unset($rooms[$token]);
|
|
}
|
|
}
|
|
$this->setRooms($rooms);
|
|
|
|
if (isset($config['auth_tokens'])) {
|
|
$this->setAuthTokens($config['auth_tokens']);
|
|
}
|
|
|
|
if (isset($config['sender_name'])) {
|
|
$this->setSenderName($config['sender_name']);
|
|
}
|
|
|
|
// Ensure talk-bot is a participant in all configured rooms
|
|
$this->ensureBotParticipants();
|
|
}
|
|
|
|
/**
|
|
* Add talk-bot user as a participant in all configured rooms.
|
|
* Required so the Chat API accepts requests authenticated as talk-bot.
|
|
* Uses AttendeeMapper directly to avoid injecting ParticipantService (17 deps).
|
|
*/
|
|
private function ensureBotParticipants(): void {
|
|
$rooms = $this->getRooms();
|
|
if (empty($rooms)) {
|
|
return;
|
|
}
|
|
|
|
// Get the talk-bot user
|
|
$botUser = $this->userManager->get('talk-bot');
|
|
if ($botUser === null) {
|
|
$this->logger->warning('NCbotwebhooks: talk-bot user not found', ['app' => self::APP_ID]);
|
|
return;
|
|
}
|
|
|
|
// Get Talk DB table prefix
|
|
$sysPrefix = $this->config->getSystemValueString('dbtableprefix', '');
|
|
$talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix);
|
|
$attendeeTable = $talkPrefix . 'talk_attendee';
|
|
|
|
// Detect Talk rooms table name
|
|
$roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room');
|
|
if ($roomTable === null) {
|
|
$this->logger->warning('NCbotwebhooks: Talk rooms table not found, skipping participant setup', [
|
|
'app' => self::APP_ID,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
foreach (array_keys($rooms) as $token) {
|
|
try {
|
|
// Resolve token → room_id via direct DB query (TalkManager::getRoomForToken removed in Talk 14+)
|
|
$stmt = $this->db->executeQuery(
|
|
'SELECT id FROM "' . $roomTable . '" WHERE token = ?',
|
|
[$token],
|
|
);
|
|
$roomId = (int)$stmt->fetchOne();
|
|
$stmt->closeCursor();
|
|
|
|
if ($roomId === 0) {
|
|
$this->logger->warning('NCbotwebhooks: room token not found in database', [
|
|
'app' => self::APP_ID,
|
|
'token' => $token,
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
// Check if talk-bot is already an attendee
|
|
try {
|
|
$this->attendeeMapper->findByActor($roomId, Attendee::ACTOR_USERS, 'talk-bot');
|
|
// Already exists — nothing to do
|
|
continue;
|
|
} catch (DoesNotExistException $e) {
|
|
// Not found — will create
|
|
}
|
|
|
|
// Create attendee record directly
|
|
$newAttendee = new Attendee();
|
|
$newAttendee->setRoomId($roomId);
|
|
$newAttendee->setActorType(Attendee::ACTOR_USERS);
|
|
$newAttendee->setActorId('talk-bot');
|
|
$newAttendee->setDisplayName('talk-bot');
|
|
$newAttendee->setParticipantType(\OCA\Talk\Participant::PERMISSIONS_DEFAULT);
|
|
$newAttendee->setPermissions(\OCA\Talk\Participant::PERMISSIONS_MAX_DEFAULT);
|
|
$newAttendee->setNotificationLevel(3); // Full notification level
|
|
$newAttendee->setFavorite(false);
|
|
$newAttendee->setArchived(false);
|
|
|
|
try {
|
|
$this->attendeeMapper->insert($newAttendee);
|
|
$this->logger->info('NCbotwebhooks: added talk-bot as participant in room ' . $token, [
|
|
'app' => self::APP_ID,
|
|
'room_id' => $roomId,
|
|
]);
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: failed to insert attendee for room ' . $token, [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('NCbotwebhooks: failed to add talk-bot to room ' . $token, [
|
|
'app' => self::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a list of image URLs — download, upload, and create rich objects.
|
|
* Returns array of rich objects.
|
|
*/
|
|
private function processImageUrls(array $urls, string $roomToken): array {
|
|
$richObjects = [];
|
|
foreach ($urls as $imageUrl) {
|
|
$this->logger->info('NCbotwebhooks: attempting image download', [
|
|
'app' => self::APP_ID,
|
|
'url' => $imageUrl,
|
|
]);
|
|
$imageData = $this->downloadImage($imageUrl);
|
|
if ($imageData === null) {
|
|
$this->logger->warning('NCbotwebhooks: image download returned null', [
|
|
'app' => self::APP_ID,
|
|
'url' => $imageUrl,
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
$fileName = basename(parse_url($imageUrl, PHP_URL_PATH)) ?: 'attachment';
|
|
$this->logger->info('NCbotwebhooks: uploading image', [
|
|
'app' => self::APP_ID,
|
|
'roomToken' => $roomToken,
|
|
'fileName' => $fileName,
|
|
'mimeType' => $imageData['mimeType'],
|
|
'size' => strlen($imageData['data']),
|
|
]);
|
|
$uploadPath = $this->uploadImage($roomToken, $fileName, $imageData['data'], $imageData['mimeType']);
|
|
if ($uploadPath !== null) {
|
|
$this->logger->info('NCbotwebhooks: image uploaded', [
|
|
'app' => self::APP_ID,
|
|
'path' => $uploadPath,
|
|
]);
|
|
$this->logger->info('NCbotwebhooks: building rich object', [
|
|
'app' => self::APP_ID,
|
|
'path' => $uploadPath,
|
|
]);
|
|
$richObj = $this->buildRichObject($uploadPath, $imageData['mimeType'], $roomToken);
|
|
if ($richObj !== null) {
|
|
$this->logger->info('NCbotwebhooks: rich object built', [
|
|
'app' => self::APP_ID,
|
|
'richObject' => json_encode($richObj),
|
|
]);
|
|
$richObjects[] = $richObj;
|
|
}
|
|
} else {
|
|
$this->logger->warning('NCbotwebhooks: image upload returned null', [
|
|
'app' => self::APP_ID,
|
|
'fileName' => $fileName,
|
|
]);
|
|
}
|
|
}
|
|
return $richObjects;
|
|
}
|
|
}
|