Files
Teknium 1f34742552 fix(discord): skip obfuscated channels in directory and backfill enumeration
Discord's Channel Obfuscation change (announced Aug 12 2026, HTTP
enforcement Nov 16 2026) dispatches channels the bot lacks VIEW_CHANNEL
on with name "___hidden___", flag 1 << 17 (CHANNEL_OBFUSCATED), and
nulled fields. Without filtering, the channel directory lists phantom
"___hidden___" entries the agent can never post to, and wildcard
missed-message backfill wastes history reads on channels that always
403.

Adds is_discord_channel_obfuscated() to gateway/platforms/helpers.py
(checks the flag bit plus the sentinel name for discord.py builds that
don't expose the new flag) and applies it at both enumeration sites.
2026-09-15 03:50:10 -07:00

659 lines
26 KiB
Python

"""Shared helpers for gateway platform adapters: message dedup, markdown
stripping, thread participation tracking, GFM table → bullets, mention-pattern
compilation, and fence-aware markdown chunking."""
import asyncio
import contextlib
import json
import logging
import re
import time
from pathlib import Path
from typing import Any, MutableMapping, Optional
from gateway.platforms.event import MessageEvent
from utils import atomic_json_write
logger = logging.getLogger(__name__)
class MessageDeduplicator:
"""TTL-based message deduplication cache (``if dedup.is_duplicate(msg_id): return``)."""
def __init__(self, max_size: int = 2000, ttl_seconds: float = 300):
self._seen: dict[str, float] = {}
self._max_size = max_size
self._ttl = ttl_seconds
def is_duplicate(self, msg_id: str) -> bool:
"""Return True if *msg_id* was already seen within the TTL window."""
if not msg_id:
return False
now = time.time()
if msg_id in self._seen:
if now - self._seen[msg_id] < self._ttl:
return True
del self._seen[msg_id] # expired: treat as new
self._seen[msg_id] = now
if len(self._seen) > self._max_size:
cutoff = now - self._ttl
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
if len(self._seen) > self._max_size:
# All entries still fresh: keep the newest so max_size holds under load.
self._seen = dict(sorted(self._seen.items(), key=lambda item: item[1])[-self._max_size:])
return False
def contains(self, msg_id: str) -> bool:
"""Return whether *msg_id* is live in the cache without inserting it."""
seen_at = self._seen.get(msg_id) if msg_id else None
if seen_at is None:
return False
if time.time() - seen_at < self._ttl:
return True
del self._seen[msg_id]
return False
def discard(self, msg_id: str) -> None:
"""Release a claimed message ID after cancelled/failed handoff."""
self._seen.pop(msg_id, None)
def clear(self):
self._seen.clear()
async def cancel_task(task: Optional[asyncio.Task]) -> None:
"""Cancel *task* and wait for it to unwind. ``None``/finished tasks are no-ops; awaiting the
current task would deadlock, so a self-cancel only requests cancellation. Exceptions the task
dies with are swallowed: at teardown nobody is left to handle them."""
if task is None or task.done():
return
task.cancel()
if task is not asyncio.current_task():
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
def bounded_put(store: MutableMapping[str, Any], key: str, value: Any, cap: int) -> None:
"""Insert into an insertion-ordered mapping with a hard size bound, evicting the oldest keys. A
re-put moves the key to the newest position so live entries outlast stale ones."""
store.pop(key, None)
store[key] = value
while len(store) > cap:
del store[next(iter(store))]
# Markdown-stripping rules, applied in order: bold, italic, bold/italic underscore,
# code fence markers, inline code, headings, links, then newline squeeze.
_STRIP_RULES = (
(re.compile(r"\*\*(.+?)\*\*", re.DOTALL), r"\1"),
(re.compile(r"\*(.+?)\*", re.DOTALL), r"\1"),
(re.compile(r"\b__(?![\s_])(.+?)(?<![\s_])__\b", re.DOTALL), r"\1"),
(re.compile(r"\b_(?![\s_])(.+?)(?<![\s_])_\b", re.DOTALL), r"\1"),
(re.compile(r"```[a-zA-Z0-9_+-]*\n?"), ""),
(re.compile(r"`(.+?)`"), r"\1"),
(re.compile(r"^#{1,6}\s+", re.MULTILINE), ""),
(re.compile(r"\[([^\]]+)\]\([^\)]+\)"), r"\1"),
(re.compile(r"\n{3,}"), "\n\n"),
)
def strip_markdown(text: str) -> str:
"""Strip markdown formatting for plain-text platforms (SMS, iMessage, etc.)."""
for pattern, repl in _STRIP_RULES:
text = pattern.sub(repl, text)
return text.strip()
class ThreadParticipationTracker:
"""Persistent set of threads the bot has participated in (``<platform>_threads.json``);
``thread_id in tracker`` checks membership, ``tracker.mark(thread_id)`` persists."""
_MAX_TRACKED = 500
def __init__(self, platform_name: str, max_tracked: int = 500):
self._platform = platform_name
self._max_tracked = max_tracked
self._threads: dict[str, None] = dict.fromkeys(str(t) for t in self._load())
def _state_path(self) -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / f"{self._platform}_threads.json"
def _load(self) -> list[str]:
try:
data = json.loads(self._state_path().read_text(encoding="utf-8"))
except Exception:
return []
return [str(thread_id) for thread_id in data] if isinstance(data, list) else []
def _save(self) -> None:
thread_list = list(self._threads)
if len(thread_list) > self._max_tracked:
thread_list = thread_list[-self._max_tracked:]
self._threads = dict.fromkeys(thread_list)
atomic_json_write(self._state_path(), thread_list, indent=None)
def mark(self, thread_id: str) -> None:
"""Mark *thread_id* as participated and persist."""
if thread_id not in self._threads:
self._threads[thread_id] = None
self._save()
def __contains__(self, thread_id: str) -> bool:
return thread_id in self._threads
def clear(self) -> None:
self._threads.clear()
def redact_phone(phone: str) -> str:
"""Redact a phone number for logging, preserving country code and last 4."""
if not phone:
return "<none>"
if len(phone) <= 8:
return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****"
return phone[:4] + "****" + phone[-4:]
# ─── GFM table → bullets. Discord calls convert_table_to_bullets(); Telegram imports the
# primitives but keeps its own MarkdownV2-aware renderer.
# Delimiter row: optional outer pipes, dash cells (optional alignment colons). Requires at
# least one internal '|' so a lone '---' rule is NOT matched.
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$')
def is_table_row(line: str) -> bool:
"""Return True if *line* could plausibly be a table data row."""
return '|' in line.strip()
def split_markdown_table_row(line: str) -> list[str]:
"""Split a GFM table row into stripped cells (delegates to agent.markdown_tables)."""
from agent.markdown_tables import split_table_row
return split_table_row(line)
def _render_table_block(table_block: list[str]) -> str:
"""Render a GFM table as bold-heading + bullet groups (same alignment logic as Telegram's
renderer: without a row-label column the full row is data and the heading bullet is skipped)."""
headers = split_markdown_table_row(table_block[0]) if len(table_block) >= 3 else []
if len(headers) < 2:
return "\n".join(table_block)
has_row_label_col = len(split_markdown_table_row(table_block[2])) == len(headers) + 1
rendered_groups: list[str] = []
for index, row in enumerate(table_block[2:], start=1):
cells = split_markdown_table_row(row)
if has_row_label_col:
heading = cells[0] if cells and cells[0] else f"Row {index}"
data_cells = cells[1:]
else:
heading = next((cell for cell in cells if cell), f"Row {index}")
data_cells = cells
data_cells = (data_cells + [""] * len(headers))[: len(headers)]
bullets = [f"• {header}: {value}" for header, value in zip(headers, data_cells)
if has_row_label_col or value != heading]
rendered_groups.append("\n".join([f"**{heading}**", *bullets]))
return "\n\n".join(rendered_groups)
def convert_table_to_bullets(text: str) -> str:
"""Rewrite GFM pipe tables into bold-heading + bullet groups; fenced code is left alone."""
if '|' not in text or '-' not in text:
return text
lines = text.split('\n')
out: list[str] = []
in_fence = False
i = 0
while i < len(lines):
line = lines[i]
is_fence_line = line.lstrip().startswith('```')
in_fence ^= is_fence_line
if not (in_fence or is_fence_line) and '|' in line and i + 1 < len(lines) \
and TABLE_SEPARATOR_RE.match(lines[i + 1]):
j = i + 2
while j < len(lines) and is_table_row(lines[j]):
j += 1
out.append(_render_table_block(lines[i:j]))
i = j
else:
out.append(line)
i += 1
return '\n'.join(out)
def compile_mention_patterns(raw, *, log_prefix: str, platform_label: str | None = None,
display_label: str | None = None, defaults: 'list[str] | None' = None,
logger_: 'logging.Logger | None' = None) -> 'list[re.Pattern]':
"""Compile regex wake-word/mention patterns from config or env values.
* **Config-style** (dingtalk, telegram): pass ``platform_label``. ``raw`` must be a
list or string (else warn + ``[]``); non-string entries skipped; info log on load.
* **Wakeword-style** (photon, bluebubbles): pass ``defaults``. ``raw`` may be None
(defaults), a string (JSON list or comma/newline separated), a list, or a scalar.
``log_prefix`` is interpolated into every log line so per-adapter output stays
byte-identical to the historical inline implementations.
"""
log = logger_ or logger
def _compile(patterns, warn_fmt, *warn_args):
compiled: list[re.Pattern] = []
for pattern in patterns:
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
log.warning(warn_fmt, log_prefix, *warn_args, pattern, exc)
return compiled
if platform_label is not None:
display = display_label or platform_label
if raw is None:
return []
patterns = [raw] if isinstance(raw, str) else raw
if not isinstance(patterns, list):
log.warning("[%s] %s mention_patterns must be a list or string; got %s",
log_prefix, platform_label, type(patterns).__name__)
return []
compiled = _compile([p for p in patterns if isinstance(p, str) and p.strip()],
"[%s] Invalid %s mention pattern %r: %s", display)
if compiled:
log.info("[%s] Loaded %d %s mention pattern(s)", log_prefix, len(compiled), display)
return compiled
if raw is None:
patterns = list(defaults or [])
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip() for line in text.splitlines() for part in line.split(",")]
else:
patterns = raw if isinstance(raw, list) else [raw]
texts = [t for t in (str(p).strip() for p in patterns) if t]
return _compile(texts, "[%s] Invalid mention pattern %r: %s")
# ─── Fence-Aware Markdown Chunking ───────────────────────────────────────────
# Shared core for gateway/stream_consumer.py (``prefer_paragraphs=False, balance_fences=True``),
# yuanbao (``prefer_paragraphs=True, balance_fences=False``) and weixin (``greedy_pack_blocks``).
def text_has_unclosed_fence(text: str) -> bool:
"""Return True when *text* ends inside an unclosed ``` code fence."""
return sum(line.startswith('```') for line in text.split('\n')) % 2 == 1
def text_ends_with_table_row(text: str) -> bool:
"""True when the last non-empty line starts and ends with ``|``."""
return _is_pipe_row(text.rstrip().split('\n')[-1])
def is_fence_atom(text: str) -> bool:
"""True when an atomic block is a code block (starts with ```)."""
return text.lstrip().startswith('```')
def _is_pipe_row(line: str) -> bool:
stripped = line.strip()
return stripped.startswith('|') and stripped.endswith('|')
def is_table_atom(text: str) -> bool:
"""True when an atomic block is a table (first line is ``|...|``)."""
return _is_pipe_row(text.split('\n')[0])
_SENTENCE_END_NEWLINE_RE = re.compile(r'[。!?.!?]\n')
def _cp_budget(text, budget, len_fn):
"""Code-point count of the longest prefix of *text* within *budget* ``len_fn`` units
(callers guarantee ``len_fn(text) > budget``, so plain ``len`` needs no search)."""
if len_fn is len:
return budget
from gateway.platforms.base import _custom_unit_to_cp # heavyweight; lazy
return _custom_unit_to_cp(text, budget, len_fn)
def split_at_paragraph_boundary(text, max_chars, len_fn=None):
"""Split at the nearest paragraph boundary within *max_chars*; return (head, tail).
Priority: blank line → newline after sentence-ending punctuation (CJK/ASCII)
→ last newline → forced split at the window boundary. ``head + tail == text``
always holds. *len_fn* measures in custom units (e.g. UTF-16 code units).
"""
_len = len_fn or len
if _len(text) <= max_chars:
return text, ''
window = text[:_cp_budget(text, max_chars, _len)]
pos = window.rfind('\n\n')
sentence_ends = [m.end() for m in _SENTENCE_END_NEWLINE_RE.finditer(window)]
cut = pos + 2 if pos > 0 else (sentence_ends[-1] if sentence_ends else 0)
if not cut:
pos = window.rfind('\n')
cut = pos + 1 if pos > 0 else len(window)
return text[:cut], text[cut:]
def split_markdown_atoms(text: str) -> "list[str]":
"""Split markdown into indivisible atoms: fenced code blocks, tables
(consecutive ``|...|`` lines) and paragraphs. Blank lines belong to no atom."""
atoms: "list[str]" = []
current_lines: "list[str]" = []
in_fence = False
def _flush_current() -> None:
if current_lines:
atom = '\n'.join(current_lines)
if atom.strip():
atoms.append(atom)
current_lines.clear()
for line in text.split('\n'):
if in_fence:
current_lines.append(line)
if line.startswith('```'):
in_fence = False
_flush_current()
elif line.startswith('```'):
_flush_current()
in_fence = True
current_lines.append(line)
elif line.strip() == '':
_flush_current()
else:
# A table line and a non-table line never share an atom.
if current_lines and _is_pipe_row(current_lines[-1]) != _is_pipe_row(line):
_flush_current()
current_lines.append(line)
_flush_current()
return atoms
def infer_block_separator(prev_chunk: str, next_chunk: str) -> str:
"""``'\\n'`` when the boundary sits at a code fence or a continued table, else ``'\\n\\n'``."""
prev_trimmed = prev_chunk.rstrip()
next_trimmed = next_chunk.lstrip()
if prev_trimmed.endswith('```') or next_trimmed.startswith('```'):
return '\n'
if text_ends_with_table_row(prev_chunk) and next_trimmed and _is_pipe_row(next_trimmed.split('\n')[0]):
return '\n'
return '\n\n'
def merge_streaming_fences(chunks: "list[str]") -> "list[str]":
"""Rejoin chunks truncated mid-fence: while chunk *i* has an unclosed fence
and a successor exists, merge the successor in via :func:`infer_block_separator`."""
result: "list[str]" = []
i = 0
while i < len(chunks):
current = chunks[i]
while text_has_unclosed_fence(current) and i + 1 < len(chunks):
current = current + infer_block_separator(current, chunks[i + 1]) + chunks[i + 1]
i += 1
result.append(current)
i += 1
return result
def balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
"""Close orphaned ``` fences at each chunk boundary and reopen (with the
original language tag) on the next, so every chunk is fence-balanced alone."""
if len(chunks) <= 1:
return chunks
out: "list[str]" = []
carry_lang = None
for chunk in chunks:
body = f"```{carry_lang}\n{chunk}" if carry_lang is not None else chunk
in_code, lang = fence_state_after(chunk, carry_lang is not None, carry_lang or "")
carry_lang = lang if in_code else None
out.append(body + "\n```" if in_code else body)
return out
def fence_state_after(text: str, in_code: bool = False, lang: str = "") -> "tuple[bool, str]":
"""Walk ``text`` line by line toggling on ``` lines; return the final (in_code, lang)."""
for line in text.split("\n"):
stripped = line.strip()
if stripped.startswith("```"):
tag = stripped[3:].split()
in_code, lang = (False, "") if in_code else (True, tag[0] if tag else "")
return in_code, lang
def greedy_pack_blocks(blocks, max_length, len_fn=None, sep="\n\n", overflow=None):
"""Greedily pack *blocks* (joined with *sep*) into chunks of at most *max_length*; an
oversized block goes through *overflow(block)* (-> list of chunks) if given, else as-is."""
_len = len_fn or len
packed: "list[str]" = []
current = ""
for block in blocks:
candidate = block if not current else f"{current}{sep}{block}"
if _len(candidate) <= max_length:
current = candidate
continue
if current:
packed.append(current)
current = ""
if _len(block) <= max_length:
current = block
elif overflow is not None:
packed.extend(overflow(block))
else:
packed.append(block)
if current:
packed.append(current)
return packed
def split_text_fence_aware(text, limit, len_fn=None, *, prefer_paragraphs=True,
balance_fences=False):
"""Split markdown into chunks of at most *limit*, respecting fences.
``prefer_paragraphs=True`` (yuanbao-derived): atoms (fences, tables, paragraphs) are
greedily merged, oversized non-atomic chunks split at paragraph boundaries, small
neighbours re-merged; a single atom larger than *limit* is emitted oversize, not broken.
``prefer_paragraphs=False`` (stream_consumer-derived): newline-preferred hard splitting
with headroom reserved for fence markers. ``balance_fences=True`` closes/reopens fences
at chunk boundaries so each chunk renders standalone.
"""
if not text:
return []
if prefer_paragraphs:
chunks = _chunk_markdown_paragraphs(text, limit, len_fn)
else:
chunks = _chunk_newline_preferred(text, limit, len_fn or len)
return balance_fences_across_chunks(chunks) if balance_fences else chunks
def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
"""Yuanbao-derived paragraph/atom chunking pipeline (see module docs)."""
_len = len_fn or len
if _len(text) <= max_chars:
return [text]
# Phase 2: greedy merge; oversized fence/table atoms stay indivisible.
chunks: "list[str]" = []
indivisible_set: "set[int]" = set()
current_parts: "list[str]" = []
current_len = 0
for atom in split_markdown_atoms(text):
atom_len = _len(atom)
sep_len = 2 if current_parts else 0
if current_len + sep_len + atom_len > max_chars and current_parts:
chunks.append('\n\n'.join(current_parts))
current_parts, current_len, sep_len = [], 0, 0
if (not current_parts and atom_len > max_chars
and (is_fence_atom(atom) or is_table_atom(atom))):
indivisible_set.add(len(chunks))
chunks.append(atom)
continue
current_parts.append(atom)
current_len += sep_len + atom_len
if current_parts:
chunks.append('\n\n'.join(current_parts))
# Phase 3: split still-oversized divisible chunks at paragraph boundaries.
result: "list[str]" = []
for idx, chunk in enumerate(chunks):
if _len(chunk) <= max_chars or idx in indivisible_set or text_has_unclosed_fence(chunk):
result.append(chunk)
continue
remaining = chunk
while _len(remaining) > max_chars:
head, remaining = split_at_paragraph_boundary(remaining, max_chars, len_fn=len_fn)
if not head:
head, remaining = remaining[:max_chars], remaining[max_chars:]
if head:
result.append(head)
if remaining:
result.append(remaining)
# Phase 4: merge small chunks with neighbours.
merged: "list[str]" = result[:1]
for chunk in result[1:]:
combined = merged[-1] + '\n\n' + chunk
if _len(combined) <= max_chars:
merged[-1] = combined
else:
merged.append(chunk)
return [c for c in merged if c]
def _chunk_newline_preferred(text, limit, len_fn):
"""Stream-consumer-derived newline-preferred splitting (no balancing)."""
if len_fn(text) <= limit:
return [text]
# Reserve headroom for fence markers a balancing pass may add.
split_limit = max(limit - 16, limit // 2, 1) if "```" in text else limit
chunks: "list[str]" = []
remaining = text
while len_fn(remaining) > split_limit:
budget = _cp_budget(remaining, split_limit, len_fn)
split_at = remaining.rfind("\n", 0, budget)
if split_at < budget // 2:
split_at = budget
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
if remaining:
chunks.append(remaining)
return chunks
# ─── Discord channel obfuscation (Bot API change, mandatory Nov 16 2026) ───────
# Channels a bot lacks VIEW_CHANNEL on are still dispatched over the Gateway with the
# name replaced by "___hidden___", sensitive fields nulled and flag 1 << 17 set; over
# HTTP they are omitted. https://discord.com/developers/docs/change-log (Aug 12, 2026)
DISCORD_CHANNEL_OBFUSCATED_FLAG = 1 << 17
DISCORD_OBFUSCATED_CHANNEL_NAME = "___hidden___"
def is_discord_channel_obfuscated(channel) -> bool:
"""True when a discord.py channel object is an obfuscated placeholder.
Skip these wherever guild channels are enumerated (channel directory, message
backfill): history reads and sends always fail with a permission error. Flag
first; the sentinel name is the fallback for discord.py builds that don't
expose the flag (a visible channel literally named ``___hidden___`` is then
also skipped — deliberate bias toward hiding). #90154
"""
try:
flag_value = channel.flags.value
except AttributeError:
flag_value = None
if isinstance(flag_value, int) and flag_value & DISCORD_CHANNEL_OBFUSCATED_FLAG:
return True
return getattr(channel, "name", None) == DISCORD_OBFUSCATED_CHANNEL_NAME
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Dict # noqa: F401,E402
from typing import TYPE_CHECKING # noqa: F401,E402
import asyncio # noqa: F401,E402
import asyncio # noqa: F401,E402
class TextBatchAggregator:
"""Aggregates rapid-fire text events into single messages.
Replaces the ``_enqueue_text_event`` / ``_flush_text_batch`` pattern
previously duplicated in telegram, discord, matrix, wecom, and feishu.
Usage::
self._text_batcher = TextBatchAggregator(
handler=self._message_handler,
batch_delay=0.6,
split_threshold=1900,
)
# In message dispatch:
if msg_type == MessageType.TEXT and self._text_batcher.is_enabled():
self._text_batcher.enqueue(event, session_key)
return
"""
def __init__(
self,
handler,
*,
batch_delay: float = 0.6,
split_delay: float = 2.0,
split_threshold: int = 4000,
):
self._handler = handler
self._batch_delay = batch_delay
self._split_delay = split_delay
self._split_threshold = split_threshold
self._pending: Dict[str, MessageEvent] = {}
self._pending_tasks: Dict[str, asyncio.Task] = {}
def is_enabled(self) -> bool:
"""Return True if batching is active (delay > 0)."""
return self._batch_delay > 0
def enqueue(self, event: MessageEvent, key: str) -> None:
"""Add *event* to the pending batch for *key*."""
chunk_len = len(event.text or "")
existing = self._pending.get(key)
if not existing:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending[key] = event
else:
existing.text = f"{existing.text}\n{event.text}"
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
# Cancel prior flush timer, start a new one
prior = self._pending_tasks.get(key)
if prior and not prior.done():
prior.cancel()
self._pending_tasks[key] = asyncio.create_task(self._flush(key))
async def _flush(self, key: str) -> None:
"""Wait then dispatch the batched event for *key*."""
current_task = self._pending_tasks.get(key)
pending = self._pending.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
# Use longer delay when the last chunk looks like a split message
delay = self._split_delay if last_len >= self._split_threshold else self._batch_delay
await asyncio.sleep(delay)
event = self._pending.pop(key, None)
if event:
try:
await self._handler(event)
except Exception:
logger.exception("[TextBatchAggregator] Error dispatching batched event for %s", key)
if self._pending_tasks.get(key) is current_task:
self._pending_tasks.pop(key, None)
def cancel_all(self) -> None:
"""Cancel all pending flush tasks."""
for task in self._pending_tasks.values():
if not task.done():
task.cancel()
self._pending_tasks.clear()
self._pending.clear()
# ---- END PLUGIN-COMPAT ----