1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/* For licensing terms, see /license.txt */ |
4
|
|
|
|
5
|
|
|
use Chamilo\CoreBundle\Entity\CourseRelUser; |
6
|
|
|
use Chamilo\CoreBundle\Entity\ResourceFile; |
7
|
|
|
use Chamilo\CoreBundle\Entity\ResourceLink; |
8
|
|
|
use Chamilo\CoreBundle\Entity\ResourceNode; |
9
|
|
|
use Chamilo\CoreBundle\Entity\Session; |
10
|
|
|
use Chamilo\CoreBundle\Entity\SessionRelCourseRelUser; |
11
|
|
|
use Chamilo\CoreBundle\Entity\User; |
12
|
|
|
use Chamilo\CoreBundle\Repository\ResourceNodeRepository; |
13
|
|
|
use Chamilo\CoreBundle\Repository\ResourceRepository; |
14
|
|
|
use Chamilo\CourseBundle\Entity\CChatConnected; |
15
|
|
|
use Chamilo\CourseBundle\Entity\CChatConversation; |
16
|
|
|
use Doctrine\Common\Collections\Criteria; |
17
|
|
|
use Michelf\MarkdownExtra; |
18
|
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile; |
19
|
|
|
|
20
|
|
|
/** |
21
|
|
|
* Course chat utils. |
22
|
|
|
*/ |
23
|
|
|
class CourseChatUtils |
24
|
|
|
{ |
25
|
|
|
private $groupId; |
26
|
|
|
private $courseId; |
27
|
|
|
private $sessionId; |
28
|
|
|
private $userId; |
29
|
|
|
|
30
|
|
|
/** @var ResourceNode */ |
31
|
|
|
private $resourceNode; |
32
|
|
|
|
33
|
|
|
/** @var ResourceRepository */ |
34
|
|
|
private $repository; |
35
|
|
|
|
36
|
|
|
/** Debug flag */ |
37
|
|
|
private $debug = false; |
38
|
|
|
|
39
|
|
|
public function __construct($courseId, $userId, $sessionId, $groupId, ResourceNode $resourceNode, ResourceRepository $repository) |
40
|
|
|
{ |
41
|
|
|
$this->courseId = (int) $courseId; |
42
|
|
|
$this->userId = (int) $userId; |
43
|
|
|
$this->sessionId = (int) $sessionId; |
44
|
|
|
$this->groupId = (int) $groupId; |
45
|
|
|
$this->resourceNode = $resourceNode; |
46
|
|
|
$this->repository = $repository; |
47
|
|
|
|
48
|
|
|
$this->dbg('construct', [ |
49
|
|
|
'courseId' => $courseId, |
50
|
|
|
'userId' => $userId, |
51
|
|
|
'sessionId' => $sessionId, |
52
|
|
|
'groupId' => $groupId, |
53
|
|
|
'parentNodeId' => $resourceNode->getId() ?? null, |
54
|
|
|
'repo' => get_class($repository), |
55
|
|
|
]); |
56
|
|
|
} |
57
|
|
|
|
58
|
|
|
/** Simple debug helper */ |
59
|
|
|
private function dbg(string $msg, array $ctx = []): void |
60
|
|
|
{ |
61
|
|
|
if (!$this->debug) { return; } |
62
|
|
|
$line = '[CourseChat] '.$msg; |
63
|
|
|
if ($ctx) { $line .= ' | '.json_encode($ctx, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); } |
|
|
|
|
64
|
|
|
error_log($line); |
65
|
|
|
} |
66
|
|
|
|
67
|
|
|
/** Build a slug out of the file title (matches our nodes like: messages-...-log-html) */ |
68
|
|
|
private function makeSlug(string $fileTitle): string |
69
|
|
|
{ |
70
|
|
|
$slug = strtolower($fileTitle); |
71
|
|
|
$slug = strtr($slug, ['.' => '-']); |
72
|
|
|
$slug = preg_replace('~[^a-z0-9\-\_]+~', '-', $slug); |
73
|
|
|
$slug = preg_replace('~-+~', '-', $slug); |
74
|
|
|
return trim($slug, '-'); |
75
|
|
|
} |
76
|
|
|
|
77
|
|
|
/** Build a base name by day and scope (all / session / group / 1:1) */ |
78
|
|
|
private function buildBasename(int $friendId = 0): string |
79
|
|
|
{ |
80
|
|
|
$dateNow = date('Y-m-d'); |
81
|
|
|
$basename = 'messages-'.$dateNow; |
82
|
|
|
|
83
|
|
|
if ($this->groupId && !$friendId) { |
84
|
|
|
$basename .= '_gid-'.$this->groupId; |
85
|
|
|
} elseif ($this->sessionId && !$friendId) { |
86
|
|
|
$basename .= '_sid-'.$this->sessionId; |
87
|
|
|
} elseif ($friendId) { |
88
|
|
|
// stable order for 1:1 (smallest id first) |
89
|
|
|
$basename .= ($this->userId < $friendId) |
90
|
|
|
? '_uid-'.$this->userId.'-'.$friendId |
91
|
|
|
: '_uid-'.$friendId.'-'.$this->userId; |
92
|
|
|
} |
93
|
|
|
return $basename; |
94
|
|
|
} |
95
|
|
|
|
96
|
|
|
/** Returns [fileTitle, slug] */ |
97
|
|
|
private function buildNames(int $friendId = 0): array |
98
|
|
|
{ |
99
|
|
|
$fileTitle = $this->buildBasename($friendId).'-log.html'; |
100
|
|
|
$slug = $this->makeSlug($fileTitle); |
101
|
|
|
return [$fileTitle, $slug]; |
102
|
|
|
} |
103
|
|
|
|
104
|
|
|
/** Create node + conversation + empty file (used only from saveMessage under a lock) */ |
105
|
|
|
private function createNodeWithResource(string $fileTitle, string $slug, ResourceNode $parentNode): ResourceNode |
|
|
|
|
106
|
|
|
{ |
107
|
|
|
$em = Database::getManager(); |
108
|
|
|
|
109
|
|
|
$this->dbg('node.create.start', ['slug' => $slug, 'title' => $fileTitle, 'parent' => $parentNode->getId()]); |
110
|
|
|
|
111
|
|
|
// temporary empty file |
112
|
|
|
$h = tmpfile(); |
113
|
|
|
fwrite($h, ''); |
114
|
|
|
$meta = stream_get_meta_data($h); |
115
|
|
|
$uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true); |
116
|
|
|
|
117
|
|
|
// conversation |
118
|
|
|
$conversation = new CChatConversation(); |
119
|
|
|
if (method_exists($conversation, 'setTitle')) { |
120
|
|
|
$conversation->setTitle($fileTitle); |
121
|
|
|
} else { |
122
|
|
|
$conversation->setResourceName($fileTitle); |
123
|
|
|
} |
124
|
|
|
$conversation->setParentResourceNode($parentNode->getId()); |
125
|
|
|
|
126
|
|
|
// node |
127
|
|
|
$node = new ResourceNode(); |
128
|
|
|
$node->setTitle($fileTitle); |
129
|
|
|
$node->setSlug($slug); |
130
|
|
|
$node->setResourceType($parentNode->getResourceType()); |
131
|
|
|
$node->setCreator(api_get_user_entity(api_get_user_id())); |
132
|
|
|
$node->setParent($parentNode); |
133
|
|
|
|
134
|
|
|
if (method_exists($conversation, 'setResourceNode')) { |
135
|
|
|
$conversation->setResourceNode($node); |
136
|
|
|
} |
137
|
|
|
|
138
|
|
|
$em->persist($node); |
139
|
|
|
$em->persist($conversation); |
140
|
|
|
|
141
|
|
|
// attach file |
142
|
|
|
$this->repository->addFile($conversation, $uploaded); |
143
|
|
|
|
144
|
|
|
// publish |
145
|
|
|
$course = api_get_course_entity(); |
146
|
|
|
$session = api_get_session_entity(); |
147
|
|
|
$group = api_get_group_entity(); |
148
|
|
|
$conversation->setParent($course); |
149
|
|
|
$conversation->addCourseLink($course, $session, $group); |
150
|
|
|
|
151
|
|
|
$em->flush(); |
152
|
|
|
|
153
|
|
|
$this->dbg('node.create.ok', ['nodeId' => $node->getId()]); |
154
|
|
|
|
155
|
|
|
return $node; |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
/** Sanitize and convert message to safe HTML */ |
159
|
|
|
public function prepareMessage($message) |
160
|
|
|
{ |
161
|
|
|
$this->dbg('prepareMessage.in', ['len' => strlen((string) $message)]); |
162
|
|
|
if (empty($message)) { |
163
|
|
|
return ''; |
164
|
|
|
} |
165
|
|
|
|
166
|
|
|
$message = trim($message); |
167
|
|
|
$message = nl2br($message); |
168
|
|
|
$message = Security::remove_XSS($message); |
169
|
|
|
|
170
|
|
|
// url -> anchor |
171
|
|
|
$message = preg_replace( |
172
|
|
|
'@((https?://)?([-\w]+\.[-\w\.]+)+\w(:\d+)?(/([-\w/_\.]*(\?\S+)?)?)*)@', |
173
|
|
|
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>', |
174
|
|
|
$message |
175
|
|
|
); |
176
|
|
|
// add http:// when missing |
177
|
|
|
$message = preg_replace( |
178
|
|
|
'/<a\s[^>]*href\s*=\s*"((?!https?:\/\/)[^"]*)"[^>]*>/i', |
179
|
|
|
'<a href="http://$1" target="_blank" rel="noopener noreferrer">', |
180
|
|
|
$message |
181
|
|
|
); |
182
|
|
|
|
183
|
|
|
$message = MarkdownExtra::defaultTransform($message); |
184
|
|
|
$this->dbg('prepareMessage.out', ['len' => strlen($message)]); |
185
|
|
|
|
186
|
|
|
return $message; |
187
|
|
|
} |
188
|
|
|
|
189
|
|
|
/** |
190
|
|
|
* Return the latest node (by id DESC) matching title or slug under the same parent. |
191
|
|
|
* Read-only; does not create. |
192
|
|
|
*/ |
193
|
|
|
private function findExistingNode(string $fileTitle, string $slug, ResourceNode $parentNode): ?ResourceNode |
194
|
|
|
{ |
195
|
|
|
$em = \Database::getManager(); |
196
|
|
|
$nodeRepo = $em->getRepository(ResourceNode::class); |
197
|
|
|
|
198
|
|
|
// latest by exact title |
199
|
|
|
$node = $nodeRepo->findOneBy( |
200
|
|
|
['title' => $fileTitle, 'parent' => $parentNode], |
201
|
|
|
['id' => 'DESC'] |
202
|
|
|
); |
203
|
|
|
if ($node) { return $node; } |
204
|
|
|
|
205
|
|
|
// latest by exact slug |
206
|
|
|
return $nodeRepo->findOneBy( |
207
|
|
|
['slug' => $slug, 'parent' => $parentNode], |
208
|
|
|
['id' => 'DESC'] |
209
|
|
|
); |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
/** |
213
|
|
|
* Append a message to the last daily file (chronological order). |
214
|
|
|
*/ |
215
|
|
|
public function saveMessage($message, $friendId = 0) |
216
|
|
|
{ |
217
|
|
|
$this->dbg('saveMessage.in', ['friendId' => (int)$friendId, 'rawLen' => strlen((string)$message)]); |
218
|
|
|
if (!is_string($message) || trim($message) === '') { return false; } |
219
|
|
|
|
220
|
|
|
// names (one file per day/scope) |
221
|
|
|
[$fileTitle, $slug] = $this->buildNames((int)$friendId); |
222
|
|
|
|
223
|
|
|
$em = Database::getManager(); |
224
|
|
|
/** @var ResourceNodeRepository $nodeRepo */ |
225
|
|
|
$nodeRepo = $em->getRepository(ResourceNode::class); |
226
|
|
|
$convRepo = $em->getRepository(CChatConversation::class); |
227
|
|
|
$rfRepo = $em->getRepository(ResourceFile::class); |
228
|
|
|
|
229
|
|
|
// parent (chat root) |
230
|
|
|
$parent = $nodeRepo->find($this->resourceNode->getId()); |
231
|
|
|
if (!$parent) { $this->dbg('saveMessage.error.noParent'); return false; } |
232
|
|
|
|
233
|
|
|
// serialize writers for the same daily file (parent+slug) |
234
|
|
|
$lockPath = sys_get_temp_dir().'/ch_chat_lock_'.$parent->getId().'_'.$slug.'.lock'; |
235
|
|
|
$lockH = @fopen($lockPath, 'c'); |
236
|
|
|
if ($lockH) { @flock($lockH, LOCK_EX); } |
|
|
|
|
237
|
|
|
|
238
|
|
|
try { |
239
|
|
|
// latest node for this day/scope (title OR slug) |
240
|
|
|
$qb = $em->createQueryBuilder(); |
241
|
|
|
$qb->select('n') |
242
|
|
|
->from(ResourceNode::class, 'n') |
243
|
|
|
->where('n.parent = :parent AND (n.title = :title OR n.slug = :slug)') |
244
|
|
|
->setParameter('parent', $parent) |
245
|
|
|
->setParameter('title', $fileTitle) |
246
|
|
|
->setParameter('slug', $slug) |
247
|
|
|
->orderBy('n.createdAt', 'DESC') |
248
|
|
|
->addOrderBy('n.id', 'DESC') |
249
|
|
|
->setMaxResults(1); |
250
|
|
|
/** @var ResourceNode|null $node */ |
251
|
|
|
$node = $qb->getQuery()->getOneOrNullResult(); |
252
|
|
|
|
253
|
|
|
// create node + conversation once |
254
|
|
|
if (!$node) { |
255
|
|
|
$conversation = new CChatConversation(); |
256
|
|
|
(method_exists($conversation, 'setTitle') |
257
|
|
|
? $conversation->setTitle($fileTitle) |
258
|
|
|
: $conversation->setResourceName($fileTitle)); |
259
|
|
|
$conversation->setParentResourceNode($parent->getId()); |
260
|
|
|
|
261
|
|
|
$node = new ResourceNode(); |
262
|
|
|
$node->setTitle($fileTitle); |
263
|
|
|
$node->setSlug($slug); |
264
|
|
|
$node->setResourceType($parent->getResourceType()); |
265
|
|
|
$node->setCreator(api_get_user_entity(api_get_user_id())); |
266
|
|
|
$node->setParent($parent); |
267
|
|
|
|
268
|
|
|
if (method_exists($conversation, 'setResourceNode')) { |
269
|
|
|
$conversation->setResourceNode($node); |
270
|
|
|
} |
271
|
|
|
|
272
|
|
|
$em->persist($node); |
273
|
|
|
$em->persist($conversation); |
274
|
|
|
|
275
|
|
|
$course = api_get_course_entity(); |
276
|
|
|
$session = api_get_session_entity(); |
277
|
|
|
$group = api_get_group_entity(); |
278
|
|
|
$conversation->setParent($course); |
279
|
|
|
$conversation->addCourseLink( |
280
|
|
|
$course, $session, $group |
281
|
|
|
); |
282
|
|
|
|
283
|
|
|
$em->flush(); |
284
|
|
|
} |
285
|
|
|
|
286
|
|
|
// ensure conversation exists for node |
287
|
|
|
$conversation = $convRepo->findOneBy(['resourceNode' => $node]); |
288
|
|
|
if (!$conversation) { |
289
|
|
|
$conversation = new CChatConversation(); |
290
|
|
|
(method_exists($conversation, 'setTitle') |
291
|
|
|
? $conversation->setTitle($fileTitle) |
292
|
|
|
: $conversation->setResourceName($fileTitle)); |
293
|
|
|
$conversation->setParentResourceNode($parent->getId()); |
294
|
|
|
if (method_exists($conversation, 'setResourceNode')) { |
295
|
|
|
$conversation->setResourceNode($node); |
296
|
|
|
} |
297
|
|
|
$em->persist($conversation); |
298
|
|
|
|
299
|
|
|
$course = api_get_course_entity(); |
300
|
|
|
$session = api_get_session_entity(); |
301
|
|
|
$group = api_get_group_entity(); |
302
|
|
|
$conversation->setParent($course); |
303
|
|
|
$conversation->addCourseLink( |
304
|
|
|
$course, $session, $group |
305
|
|
|
); |
306
|
|
|
|
307
|
|
|
$em->flush(); |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
// build message bubble |
311
|
|
|
$user = api_get_user_entity($this->userId); |
312
|
|
|
$isMaster = api_is_course_admin(); |
313
|
|
|
$timeNow = date('d/m/y H:i:s'); |
314
|
|
|
$userPhoto = \UserManager::getUserPicture($this->userId); |
315
|
|
|
$htmlMsg = $this->prepareMessage($message); |
316
|
|
|
|
317
|
|
|
$bubble = $isMaster |
318
|
|
|
? '<div class="message-teacher"><div class="content-message"><div class="chat-message-block-name">' |
319
|
|
|
.\UserManager::formatUserFullName($user).'</div><div class="chat-message-block-content">' |
320
|
|
|
.$htmlMsg.'</div><div class="message-date">'.$timeNow |
321
|
|
|
.'</div></div><div class="icon-message"></div><img class="chat-image" src="'.$userPhoto.'"></div>' |
322
|
|
|
: '<div class="message-student"><img class="chat-image" src="'.$userPhoto.'"><div class="icon-message"></div>' |
323
|
|
|
.'<div class="content-message"><div class="chat-message-block-name">'.\UserManager::formatUserFullName($user) |
324
|
|
|
.'</div><div class="chat-message-block-content">'.$htmlMsg.'</div><div class="message-date">' |
325
|
|
|
.$timeNow.'</div></div></div>'; |
326
|
|
|
|
327
|
|
|
// always target latest ResourceFile for today (by id desc) |
328
|
|
|
$rfQb = $em->createQueryBuilder(); |
329
|
|
|
$rf = $rfQb->select('rf') |
330
|
|
|
->from(ResourceFile::class, 'rf') |
331
|
|
|
->where('rf.resourceNode = :node AND rf.originalName = :name') |
332
|
|
|
->setParameter('node', $node) |
333
|
|
|
->setParameter('name', $fileTitle) |
334
|
|
|
->orderBy('rf.id', 'DESC') |
335
|
|
|
->setMaxResults(1) |
336
|
|
|
->getQuery() |
337
|
|
|
->getOneOrNullResult(); |
338
|
|
|
|
339
|
|
|
// read current content and append |
340
|
|
|
$existing = ''; |
341
|
|
|
if ($rf) { |
342
|
|
|
try { $existing = $nodeRepo->getResourceNodeFileContent($node, $rf) ?? ''; } |
343
|
|
|
catch (\Throwable $e) { $existing = ''; } |
344
|
|
|
} |
345
|
|
|
$newContent = $existing.$bubble; |
346
|
|
|
|
347
|
|
|
// write back (reuse same physical path) |
348
|
|
|
if ($rf) { |
349
|
|
|
$fs = $nodeRepo->getFileSystem(); |
350
|
|
|
$fileName = $nodeRepo->getFilename($rf); |
351
|
|
|
if ($fs->fileExists($fileName)) { $fs->delete($fileName); } |
352
|
|
|
$fs->write($fileName, $newContent); |
353
|
|
|
if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($newContent)); $em->persist($rf); } |
354
|
|
|
$em->flush(); |
355
|
|
|
} else { |
356
|
|
|
// first write of the day → create the ResourceFile with the whole content |
357
|
|
|
if (method_exists($this->repository, 'addFileFromString')) { |
358
|
|
|
$this->repository->addFileFromString($conversation, $fileTitle, 'text/html', $newContent, true); |
359
|
|
|
$em->flush(); |
360
|
|
|
} else { |
361
|
|
|
$h = tmpfile(); fwrite($h, $newContent); |
362
|
|
|
$meta = stream_get_meta_data($h); |
363
|
|
|
$uploaded = new UploadedFile( |
364
|
|
|
$meta['uri'], $fileTitle, 'text/html', null, true |
365
|
|
|
); |
366
|
|
|
$this->repository->addFile($conversation, $uploaded); |
367
|
|
|
$em->flush(); |
368
|
|
|
} |
369
|
|
|
} |
370
|
|
|
|
371
|
|
|
$this->dbg('saveMessage.append.ok', ['nodeId' => $node->getId(), 'bytes' => strlen($newContent)]); |
372
|
|
|
return true; |
373
|
|
|
|
374
|
|
|
} catch (\Throwable $e) { |
375
|
|
|
$this->dbg('saveMessage.error', ['err' => $e->getMessage()]); |
376
|
|
|
return false; |
377
|
|
|
} finally { |
378
|
|
|
if ($lockH) { @flock($lockH, LOCK_UN); @fclose($lockH); } |
|
|
|
|
379
|
|
|
} |
380
|
|
|
} |
381
|
|
|
|
382
|
|
|
/** |
383
|
|
|
* Read the last daily file HTML (optionally reset it). |
384
|
|
|
*/ |
385
|
|
|
public function readMessages($reset = false, $friendId = 0) |
386
|
|
|
{ |
387
|
|
|
[$fileTitle, $slug] = $this->buildNames((int)$friendId); |
388
|
|
|
|
389
|
|
|
$this->dbg('readMessages.in', [ |
390
|
|
|
'friendId' => (int)$friendId, |
391
|
|
|
'reset' => (bool)$reset, |
392
|
|
|
'file' => $fileTitle, |
393
|
|
|
'slug' => $slug, |
394
|
|
|
]); |
395
|
|
|
|
396
|
|
|
$em = \Database::getManager(); |
397
|
|
|
/** @var ResourceNodeRepository $nodeRepo */ |
398
|
|
|
$nodeRepo = $em->getRepository(ResourceNode::class); |
399
|
|
|
|
400
|
|
|
$parent = $nodeRepo->find($this->resourceNode->getId()); |
401
|
|
|
if (!$parent) { $this->dbg('readMessages.error.noParent'); return ''; } |
402
|
|
|
|
403
|
|
|
// read-only: do not create |
404
|
|
|
$node = $this->findExistingNode($fileTitle, $slug, $parent); |
405
|
|
|
if (!$node) { $this->dbg('readMessages.notfound'); return ''; } |
406
|
|
|
|
407
|
|
|
// locate the same ResourceFile by originalName (latest id desc) |
408
|
|
|
$rfRepo = $em->getRepository(ResourceFile::class); |
409
|
|
|
/** @var ResourceFile|null $rf */ |
410
|
|
|
$rf = $rfRepo->findOneBy( |
411
|
|
|
['resourceNode' => $node, 'originalName' => $fileTitle], |
412
|
|
|
['id' => 'DESC'] |
413
|
|
|
); |
414
|
|
|
|
415
|
|
|
// optional reset |
416
|
|
|
if ($reset) { |
417
|
|
|
$target = $rf ?: ($node->getResourceFiles()->first() ?: null); |
418
|
|
|
if ($target) { |
419
|
|
|
$fs = $nodeRepo->getFileSystem(); |
420
|
|
|
$fileName = $nodeRepo->getFilename($target); |
421
|
|
|
if ($fs->fileExists($fileName)) { |
422
|
|
|
$fs->delete($fileName); |
423
|
|
|
$fs->write($fileName, ''); |
424
|
|
|
} |
425
|
|
|
if (method_exists($target, 'setSize')) { $target->setSize(0); $em->persist($target); } |
426
|
|
|
$em->flush(); |
427
|
|
|
$this->dbg('readMessages.reset.ok', ['nodeId' => $node->getId(), 'rfId' => $target->getId()]); |
428
|
|
|
} |
429
|
|
|
} |
430
|
|
|
|
431
|
|
|
try { |
432
|
|
|
// primary: exact RF by originalName |
433
|
|
|
if ($rf) { |
434
|
|
|
$html = $nodeRepo->getResourceNodeFileContent($node, $rf); |
435
|
|
|
$this->dbg('readMessages.out.byOriginalName', [ |
436
|
|
|
'nodeId' => $node->getId(), |
437
|
|
|
'rfId' => $rf->getId(), |
438
|
|
|
'bytes' => strlen($html ?? ''), |
439
|
|
|
]); |
440
|
|
|
return $html ?? ''; |
441
|
|
|
} |
442
|
|
|
|
443
|
|
|
// fallback: first attached file (covers legacy hashed names) |
444
|
|
|
$html = $nodeRepo->getResourceNodeFileContent($node); |
445
|
|
|
$this->dbg('readMessages.out.fallbackFirst', [ |
446
|
|
|
'nodeId' => $node->getId(), |
447
|
|
|
'bytes' => strlen($html ?? ''), |
448
|
|
|
]); |
449
|
|
|
return $html ?? ''; |
450
|
|
|
|
451
|
|
|
} catch (\Throwable $e) { |
452
|
|
|
$this->dbg('readMessages.read.error', ['err' => $e->getMessage()]); |
453
|
|
|
return ''; |
454
|
|
|
} |
455
|
|
|
} |
456
|
|
|
|
457
|
|
|
/** Force a user to exit all course chat connections */ |
458
|
|
|
public static function exitChat($userId) |
459
|
|
|
{ |
460
|
|
|
$listCourse = CourseManager::get_courses_list_by_user_id($userId); |
461
|
|
|
foreach ($listCourse as $course) { |
462
|
|
|
Database::getManager() |
463
|
|
|
->createQuery(' |
464
|
|
|
DELETE FROM ChamiloCourseBundle:CChatConnected ccc |
465
|
|
|
WHERE ccc.cId = :course AND ccc.userId = :user |
466
|
|
|
') |
467
|
|
|
->execute([ |
468
|
|
|
'course' => intval($course['real_id']), |
469
|
|
|
'user' => intval($userId), |
470
|
|
|
]); |
471
|
|
|
} |
472
|
|
|
} |
473
|
|
|
|
474
|
|
|
/** Remove inactive connections (simple heartbeat) */ |
475
|
|
|
public function disconnectInactiveUsers(): void |
476
|
|
|
{ |
477
|
|
|
$em = Database::getManager(); |
478
|
|
|
$extraCondition = $this->groupId |
479
|
|
|
? "AND ccc.toGroupId = {$this->groupId}" |
480
|
|
|
: "AND ccc.sessionId = {$this->sessionId}"; |
481
|
|
|
|
482
|
|
|
$connectedUsers = $em |
483
|
|
|
->createQuery(" |
484
|
|
|
SELECT ccc FROM ChamiloCourseBundle:CChatConnected ccc |
485
|
|
|
WHERE ccc.cId = :course $extraCondition |
486
|
|
|
") |
487
|
|
|
->setParameter('course', $this->courseId) |
488
|
|
|
->getResult(); |
489
|
|
|
|
490
|
|
|
$now = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC')); |
491
|
|
|
$nowTs = $now->getTimestamp(); |
492
|
|
|
|
493
|
|
|
/** @var CChatConnected $connection */ |
494
|
|
|
foreach ($connectedUsers as $connection) { |
495
|
|
|
$lastTs = $connection->getLastConnection()->getTimestamp(); |
496
|
|
|
if (0 !== strcmp($now->format('Y-m-d'), $connection->getLastConnection()->format('Y-m-d'))) { |
497
|
|
|
continue; |
498
|
|
|
} |
499
|
|
|
if (($nowTs - $lastTs) <= 5) { |
500
|
|
|
continue; |
501
|
|
|
} |
502
|
|
|
|
503
|
|
|
$em |
504
|
|
|
->createQuery(' |
505
|
|
|
DELETE FROM ChamiloCourseBundle:CChatConnected ccc |
506
|
|
|
WHERE ccc.cId = :course AND ccc.userId = :user AND ccc.toGroupId = :group |
507
|
|
|
') |
508
|
|
|
->execute([ |
509
|
|
|
'course' => $this->courseId, |
510
|
|
|
'user' => $connection->getUserId(), |
511
|
|
|
'group' => $this->groupId, |
512
|
|
|
]); |
513
|
|
|
} |
514
|
|
|
} |
515
|
|
|
|
516
|
|
|
/** Keep (or create) the "connected" record for current user */ |
517
|
|
|
public function keepUserAsConnected(): void |
518
|
|
|
{ |
519
|
|
|
$em = Database::getManager(); |
520
|
|
|
$extraCondition = $this->groupId |
521
|
|
|
? 'AND ccc.toGroupId = '.$this->groupId |
522
|
|
|
: 'AND ccc.sessionId = '.$this->sessionId; |
523
|
|
|
|
524
|
|
|
$currentTime = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC')); |
525
|
|
|
|
526
|
|
|
/** @var CChatConnected|null $connection */ |
527
|
|
|
$connection = $em |
528
|
|
|
->createQuery(" |
529
|
|
|
SELECT ccc FROM ChamiloCourseBundle:CChatConnected ccc |
530
|
|
|
WHERE ccc.userId = :user AND ccc.cId = :course $extraCondition |
531
|
|
|
") |
532
|
|
|
->setParameters([ |
533
|
|
|
'user' => $this->userId, |
534
|
|
|
'course' => $this->courseId, |
535
|
|
|
]) |
536
|
|
|
->getOneOrNullResult(); |
537
|
|
|
|
538
|
|
|
if ($connection) { |
539
|
|
|
$connection->setLastConnection($currentTime); |
540
|
|
|
$em->persist($connection); |
541
|
|
|
$em->flush(); |
542
|
|
|
return; |
543
|
|
|
} |
544
|
|
|
|
545
|
|
|
$connection = new CChatConnected(); |
546
|
|
|
$connection |
547
|
|
|
->setCId($this->courseId) |
548
|
|
|
->setUserId($this->userId) |
549
|
|
|
->setLastConnection($currentTime) |
550
|
|
|
->setSessionId($this->sessionId) |
551
|
|
|
->setToGroupId($this->groupId); |
552
|
|
|
|
553
|
|
|
$em->persist($connection); |
554
|
|
|
$em->flush(); |
555
|
|
|
} |
556
|
|
|
|
557
|
|
|
/** Legacy helper (kept for BC) */ |
558
|
|
|
public function getFileName($absolute = false, $friendId = 0): string |
559
|
|
|
{ |
560
|
|
|
$base = $this->buildBasename((int)$friendId).'.log.html'; |
561
|
|
|
if (!$absolute) { return $base; } |
562
|
|
|
|
563
|
|
|
$document_path = '/document'; |
564
|
|
|
$chatPath = $document_path.'/chat_files/'; |
565
|
|
|
|
566
|
|
|
if ($this->groupId) { |
567
|
|
|
$group_info = GroupManager::get_group_properties($this->groupId); |
568
|
|
|
$chatPath = $document_path.$group_info['directory'].'/chat_files/'; |
569
|
|
|
} |
570
|
|
|
|
571
|
|
|
return $chatPath.$base; |
572
|
|
|
} |
573
|
|
|
|
574
|
|
|
/** Count users online (simple 5s heartbeat window) */ |
575
|
|
|
public function countUsersOnline(): int |
576
|
|
|
{ |
577
|
|
|
$date = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC')); |
578
|
|
|
$date->modify('-5 seconds'); |
579
|
|
|
|
580
|
|
|
$extraCondition = $this->groupId |
581
|
|
|
? 'AND ccc.toGroupId = '.$this->groupId |
582
|
|
|
: 'AND ccc.sessionId = '.$this->sessionId; |
583
|
|
|
|
584
|
|
|
$number = Database::getManager() |
585
|
|
|
->createQuery(" |
586
|
|
|
SELECT COUNT(ccc.userId) FROM ChamiloCourseBundle:CChatConnected ccc |
587
|
|
|
WHERE ccc.lastConnection > :date AND ccc.cId = :course $extraCondition |
588
|
|
|
") |
589
|
|
|
->setParameters([ |
590
|
|
|
'date' => $date, |
591
|
|
|
'course' => $this->courseId, |
592
|
|
|
]) |
593
|
|
|
->getSingleScalarResult(); |
594
|
|
|
|
595
|
|
|
return (int) $number; |
596
|
|
|
} |
597
|
|
|
|
598
|
|
|
/** Return basic info for connected/eligible users */ |
599
|
|
|
public function listUsersOnline(): array |
600
|
|
|
{ |
601
|
|
|
$subscriptions = $this->getUsersSubscriptions(); |
602
|
|
|
$usersInfo = []; |
603
|
|
|
|
604
|
|
|
if ($this->groupId) { |
605
|
|
|
/** @var User $groupUser */ |
606
|
|
|
foreach ($subscriptions as $groupUser) { |
607
|
|
|
$usersInfo[] = $this->formatUser($groupUser, $groupUser->getStatus()); |
608
|
|
|
} |
609
|
|
|
} else { |
610
|
|
|
/** @var CourseRelUser|SessionRelCourseRelUser $subscription */ |
611
|
|
|
foreach ($subscriptions as $subscription) { |
612
|
|
|
$user = $subscription->getUser(); |
613
|
|
|
$usersInfo[] = $this->formatUser( |
614
|
|
|
$user, |
615
|
|
|
$this->sessionId ? $user->getStatus() : $subscription->getStatus() |
616
|
|
|
); |
617
|
|
|
} |
618
|
|
|
} |
619
|
|
|
|
620
|
|
|
return $usersInfo; |
621
|
|
|
} |
622
|
|
|
|
623
|
|
|
/** Normalize user card info */ |
624
|
|
|
private function formatUser(User $user, $status): array |
625
|
|
|
{ |
626
|
|
|
return [ |
627
|
|
|
'id' => $user->getId(), |
628
|
|
|
'firstname' => $user->getFirstname(), |
629
|
|
|
'lastname' => $user->getLastname(), |
630
|
|
|
'status' => $status, |
631
|
|
|
'image_url' => UserManager::getUserPicture($user->getId()), |
632
|
|
|
'profile_url' => api_get_path(WEB_CODE_PATH).'social/profile.php?u='.$user->getId(), |
633
|
|
|
'complete_name' => UserManager::formatUserFullName($user), |
634
|
|
|
'username' => $user->getUsername(), |
635
|
|
|
'email' => $user->getEmail(), |
636
|
|
|
'isConnected' => $this->userIsConnected($user->getId()), |
637
|
|
|
]; |
638
|
|
|
} |
639
|
|
|
|
640
|
|
|
/** Fetch subscriptions (course / session / group) */ |
641
|
|
|
private function getUsersSubscriptions() |
642
|
|
|
{ |
643
|
|
|
$em = Database::getManager(); |
644
|
|
|
|
645
|
|
|
if ($this->groupId) { |
646
|
|
|
$students = $em |
647
|
|
|
->createQuery( |
648
|
|
|
'SELECT u FROM ChamiloCoreBundle:User u |
649
|
|
|
INNER JOIN ChamiloCourseBundle:CGroupRelUser gru |
650
|
|
|
WITH u.id = gru.userId AND gru.cId = :course |
651
|
|
|
WHERE u.id != :user AND gru.groupId = :group |
652
|
|
|
AND u.active = true' |
653
|
|
|
) |
654
|
|
|
->setParameters(['course' => $this->courseId, 'user' => $this->userId, 'group' => $this->groupId]) |
655
|
|
|
->getResult(); |
656
|
|
|
|
657
|
|
|
$tutors = $em |
658
|
|
|
->createQuery( |
659
|
|
|
'SELECT u FROM ChamiloCoreBundle:User u |
660
|
|
|
INNER JOIN ChamiloCourseBundle:CGroupRelTutor grt |
661
|
|
|
WITH u.id = grt.userId AND grt.cId = :course |
662
|
|
|
WHERE u.id != :user AND grt.groupId = :group |
663
|
|
|
AND u.active = true' |
664
|
|
|
) |
665
|
|
|
->setParameters(['course' => $this->courseId, 'user' => $this->userId, 'group' => $this->groupId]) |
666
|
|
|
->getResult(); |
667
|
|
|
|
668
|
|
|
return array_merge($tutors, $students); |
669
|
|
|
} |
670
|
|
|
|
671
|
|
|
$course = api_get_course_entity($this->courseId); |
672
|
|
|
|
673
|
|
|
if ($this->sessionId) { |
674
|
|
|
$session = api_get_session_entity($this->sessionId); |
675
|
|
|
$criteria = Criteria::create()->where(Criteria::expr()->eq('course', $course)); |
676
|
|
|
$userCoach = api_is_course_session_coach($this->userId, $course->getId(), $session->getId()); |
677
|
|
|
|
678
|
|
|
if ('true' === api_get_setting('chat.course_chat_restrict_to_coach')) { |
679
|
|
|
if ($userCoach) { |
680
|
|
|
$criteria->andWhere(Criteria::expr()->eq('status', Session::STUDENT)); |
681
|
|
|
} else { |
682
|
|
|
$criteria->andWhere(Criteria::expr()->eq('status', Session::COURSE_COACH)); |
683
|
|
|
} |
684
|
|
|
} |
685
|
|
|
|
686
|
|
|
$criteria->orderBy(['status' => Criteria::DESC]); |
687
|
|
|
|
688
|
|
|
return $session |
689
|
|
|
->getUserCourseSubscriptions() |
|
|
|
|
690
|
|
|
->matching($criteria) |
691
|
|
|
->filter(function (SessionRelCourseRelUser $scru) { |
692
|
|
|
return $scru->getUser()->isActive(); |
693
|
|
|
}); |
694
|
|
|
} |
695
|
|
|
|
696
|
|
|
return $course |
697
|
|
|
->getUsers() |
698
|
|
|
->filter(function (CourseRelUser $cru) { |
699
|
|
|
return $cru->getUser()->isActive(); |
700
|
|
|
}); |
701
|
|
|
} |
702
|
|
|
|
703
|
|
|
/** Quick online check for one user */ |
704
|
|
|
private function userIsConnected($userId): int |
705
|
|
|
{ |
706
|
|
|
$date = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC')); |
707
|
|
|
$date->modify('-5 seconds'); |
708
|
|
|
|
709
|
|
|
$extraCondition = $this->groupId |
710
|
|
|
? 'AND ccc.toGroupId = '.$this->groupId |
711
|
|
|
: 'AND ccc.sessionId = '.$this->sessionId; |
712
|
|
|
|
713
|
|
|
$number = Database::getManager() |
714
|
|
|
->createQuery(" |
715
|
|
|
SELECT COUNT(ccc.userId) FROM ChamiloCourseBundle:CChatConnected ccc |
716
|
|
|
WHERE ccc.lastConnection > :date AND ccc.cId = :course AND ccc.userId = :user $extraCondition |
717
|
|
|
") |
718
|
|
|
->setParameters([ |
719
|
|
|
'date' => $date, |
720
|
|
|
'course' => $this->courseId, |
721
|
|
|
'user' => $userId, |
722
|
|
|
]) |
723
|
|
|
->getSingleScalarResult(); |
724
|
|
|
|
725
|
|
|
return (int) $number; |
726
|
|
|
} |
727
|
|
|
} |
728
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.